mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-16 14:17:10 +00:00
chore: allow passing scope id for watsonx inferencing (#18959)
* chore: allow inference with space * make lint and make format
This commit is contained in:
@@ -48,7 +48,7 @@ class IBMWatsonXAudioTranscriptionConfig(
|
||||
) -> Dict:
|
||||
"""
|
||||
Validate environment for audio transcription.
|
||||
|
||||
|
||||
Removes Content-Type header so httpx can set multipart/form-data automatically.
|
||||
"""
|
||||
result = IBMWatsonXMixin.validate_environment(
|
||||
@@ -88,31 +88,37 @@ class IBMWatsonXAudioTranscriptionConfig(
|
||||
) -> AudioTranscriptionRequestData:
|
||||
"""
|
||||
Transform the audio transcription request for WatsonX.
|
||||
|
||||
|
||||
WatsonX expects multipart/form-data with:
|
||||
- file: the audio file
|
||||
- model: the model name (without watsonx/ prefix)
|
||||
- project_id: the project ID (as form field, not query param)
|
||||
- space_id: the space ID (as form field, not query param)
|
||||
- other optional params
|
||||
"""
|
||||
# Use common utility to process the audio file
|
||||
processed_audio = process_audio_file(audio_file)
|
||||
|
||||
# Get API params to extract project_id
|
||||
api_params = _get_api_params(params=optional_params.copy())
|
||||
|
||||
|
||||
# Get API params to extract project_id or space_id
|
||||
api_params = _get_api_params(params=optional_params.copy(), model=model)
|
||||
|
||||
# Initialize form data with required fields
|
||||
form_data: WatsonXAudioTranscriptionRequestBody = {
|
||||
"model": model,
|
||||
"project_id": api_params.get("project_id", ""),
|
||||
}
|
||||
|
||||
form_data: WatsonXAudioTranscriptionRequestBody = {"model": model}
|
||||
|
||||
project_id = api_params.get("project_id")
|
||||
space_id = api_params.get("space_id")
|
||||
|
||||
if project_id:
|
||||
form_data["project_id"] = project_id
|
||||
elif space_id:
|
||||
form_data["space_id"] = space_id
|
||||
|
||||
# Add supported OpenAI params to form data
|
||||
supported_params = self.get_supported_openai_params(model)
|
||||
for key, value in optional_params.items():
|
||||
if key in supported_params and value is not None:
|
||||
form_data[key] = value # type: ignore
|
||||
|
||||
|
||||
# Prepare files dict with the audio file
|
||||
files = {
|
||||
"file": (
|
||||
@@ -121,10 +127,10 @@ class IBMWatsonXAudioTranscriptionConfig(
|
||||
processed_audio.content_type,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# Convert TypedDict to regular dict for AudioTranscriptionRequestData
|
||||
form_data_dict: Dict[str, Any] = dict(form_data)
|
||||
|
||||
|
||||
return AudioTranscriptionRequestData(data=form_data_dict, files=files)
|
||||
|
||||
def get_complete_url(
|
||||
@@ -140,8 +146,8 @@ class IBMWatsonXAudioTranscriptionConfig(
|
||||
Construct the complete URL for WatsonX audio transcription.
|
||||
|
||||
URL format: {api_base}/ml/v1/audio/transcriptions?version={version}
|
||||
|
||||
Note: project_id is sent as form data, not as a query parameter
|
||||
|
||||
Note: project_id or space_id is sent as form data, not as a query parameter
|
||||
"""
|
||||
# Get base URL
|
||||
url = self._get_base_url(api_base=api_base)
|
||||
@@ -151,9 +157,10 @@ class IBMWatsonXAudioTranscriptionConfig(
|
||||
url = f"{url}/ml/v1/audio/transcriptions"
|
||||
|
||||
# Add version parameter (only version in query string, not project_id)
|
||||
api_version = optional_params.get(
|
||||
"api_version", None
|
||||
) or litellm.WATSONX_DEFAULT_API_VERSION
|
||||
api_version = (
|
||||
optional_params.get("api_version", None)
|
||||
or litellm.WATSONX_DEFAULT_API_VERSION
|
||||
)
|
||||
url = f"{url}?version={api_version}"
|
||||
|
||||
return url
|
||||
@@ -164,7 +171,7 @@ class IBMWatsonXAudioTranscriptionConfig(
|
||||
) -> TranscriptionResponse:
|
||||
"""
|
||||
Transform the audio transcription response from WatsonX.
|
||||
|
||||
|
||||
WatsonX may include a 'model' field in the response, which needs to be
|
||||
removed before creating the TranscriptionResponse object.
|
||||
"""
|
||||
@@ -179,26 +186,30 @@ class IBMWatsonXAudioTranscriptionConfig(
|
||||
# TranscriptionResponse only accepts 'text' and 'usage' in __init__()
|
||||
text = raw_response_json.get("text")
|
||||
usage = raw_response_json.get("usage")
|
||||
|
||||
|
||||
# Create response with only valid fields
|
||||
response_kwargs = {}
|
||||
if text is not None:
|
||||
response_kwargs["text"] = text
|
||||
if usage is not None:
|
||||
response_kwargs["usage"] = usage
|
||||
|
||||
|
||||
if not response_kwargs:
|
||||
raise ValueError(
|
||||
"Invalid response format. Received response does not match the expected format. Got: ",
|
||||
raw_response_json,
|
||||
)
|
||||
|
||||
|
||||
response = TranscriptionResponse(**response_kwargs)
|
||||
|
||||
|
||||
# Add other fields using dictionary-style assignment (like duration, task, etc.)
|
||||
# Skip fields that TranscriptionResponse doesn't accept in __init__()
|
||||
for key, value in raw_response_json.items():
|
||||
if key not in ["text", "usage", "model"]: # text/usage already set, model should be excluded
|
||||
if key not in [
|
||||
"text",
|
||||
"usage",
|
||||
"model",
|
||||
]: # text/usage already set, model should be excluded
|
||||
response[key] = value
|
||||
|
||||
|
||||
return response
|
||||
|
||||
@@ -40,7 +40,7 @@ class WatsonXChatHandler(OpenAILikeChatHandler):
|
||||
streaming_decoder: Optional[CustomStreamingDecoder] = None,
|
||||
fake_stream: bool = False,
|
||||
):
|
||||
api_params = _get_api_params(params=optional_params)
|
||||
api_params = _get_api_params(params=optional_params, model=model)
|
||||
|
||||
## UPDATE HEADERS
|
||||
headers = watsonx_chat_transformation.validate_environment(
|
||||
|
||||
@@ -10,7 +10,6 @@ from litellm import verbose_logger
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.watsonx import (
|
||||
WatsonXAIEndpoint,
|
||||
WatsonXAPIParams,
|
||||
WatsonXModelPattern,
|
||||
)
|
||||
|
||||
@@ -115,18 +114,6 @@ class IBMWatsonXChatConfig(IBMWatsonXMixin, OpenAIGPTConfig):
|
||||
)
|
||||
return url
|
||||
|
||||
def _prepare_payload(self, model: str, api_params: WatsonXAPIParams) -> dict:
|
||||
"""
|
||||
Prepare payload for deployment models.
|
||||
Deployment models cannot have 'model_id' or 'model' in the request body.
|
||||
"""
|
||||
payload: dict = {}
|
||||
payload["model_id"] = None if model.startswith("deployment/") else model
|
||||
payload["project_id"] = (
|
||||
None if model.startswith("deployment/") else api_params["project_id"]
|
||||
)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _apply_prompt_template_core(
|
||||
model: str, messages: List[Dict[str, str]], hf_template_fn
|
||||
|
||||
@@ -80,9 +80,7 @@ def _generate_watsonx_token(api_key: Optional[str], token: Optional[str]) -> str
|
||||
return token
|
||||
|
||||
|
||||
def _get_api_params(
|
||||
params: dict,
|
||||
) -> WatsonXAPIParams:
|
||||
def _get_api_params(params: dict, model: Optional[str] = None) -> WatsonXAPIParams:
|
||||
"""
|
||||
Find watsonx.ai credentials in the params or environment variables and return the headers for authentication.
|
||||
"""
|
||||
@@ -118,10 +116,15 @@ def _get_api_params(
|
||||
or get_secret_str("SPACE_ID")
|
||||
)
|
||||
|
||||
if project_id is None:
|
||||
if (
|
||||
project_id is None
|
||||
and space_id is None
|
||||
and model is not None
|
||||
and not model.startswith("deployment/")
|
||||
):
|
||||
raise WatsonXAIError(
|
||||
status_code=401,
|
||||
message="Error: Watsonx project_id not set. Set WX_PROJECT_ID in environment variables or pass in as a parameter.",
|
||||
message="Error: Watsonx project_id and space_id not set. Set WX_PROJECT_ID or WX_SPACE_ID in environment variables or pass in as a parameter.",
|
||||
)
|
||||
|
||||
return WatsonXAPIParams(
|
||||
@@ -146,7 +149,9 @@ async def _aconvert_watsonx_messages_core(
|
||||
model_prompt_dict = custom_prompt_dict[model]
|
||||
return ptf.custom_prompt(
|
||||
messages=messages,
|
||||
role_dict=model_prompt_dict.get("role_dict", model_prompt_dict.get("roles")),
|
||||
role_dict=model_prompt_dict.get(
|
||||
"role_dict", model_prompt_dict.get("roles")
|
||||
),
|
||||
initial_prompt_value=model_prompt_dict.get("initial_prompt_value", ""),
|
||||
final_prompt_value=model_prompt_dict.get("final_prompt_value", ""),
|
||||
bos_token=model_prompt_dict.get("bos_token", ""),
|
||||
@@ -180,7 +185,9 @@ def _convert_watsonx_messages_core(
|
||||
model_prompt_dict = custom_prompt_dict[model]
|
||||
return ptf.custom_prompt(
|
||||
messages=messages,
|
||||
role_dict=model_prompt_dict.get("role_dict", model_prompt_dict.get("roles")),
|
||||
role_dict=model_prompt_dict.get(
|
||||
"role_dict", model_prompt_dict.get("roles")
|
||||
),
|
||||
initial_prompt_value=model_prompt_dict.get("initial_prompt_value", ""),
|
||||
final_prompt_value=model_prompt_dict.get("final_prompt_value", ""),
|
||||
bos_token=model_prompt_dict.get("bos_token", ""),
|
||||
@@ -200,7 +207,10 @@ def _convert_watsonx_messages_core(
|
||||
|
||||
|
||||
async def aconvert_watsonx_messages_to_prompt(
|
||||
model: str, messages: List[AllMessageValues], provider: str, custom_prompt_dict: Dict
|
||||
model: str,
|
||||
messages: List[AllMessageValues],
|
||||
provider: str,
|
||||
custom_prompt_dict: Dict,
|
||||
) -> str:
|
||||
"""Async version of convert_watsonx_messages_to_prompt"""
|
||||
from litellm.llms.watsonx.chat.transformation import IBMWatsonXChatConfig
|
||||
@@ -215,7 +225,10 @@ async def aconvert_watsonx_messages_to_prompt(
|
||||
|
||||
|
||||
def convert_watsonx_messages_to_prompt(
|
||||
model: str, messages: List[AllMessageValues], provider: str, custom_prompt_dict: Dict
|
||||
model: str,
|
||||
messages: List[AllMessageValues],
|
||||
provider: str,
|
||||
custom_prompt_dict: Dict,
|
||||
) -> str:
|
||||
"""Sync version of convert_watsonx_messages_to_prompt"""
|
||||
from litellm.llms.watsonx.chat.transformation import IBMWatsonXChatConfig
|
||||
@@ -254,7 +267,8 @@ class IBMWatsonXMixin:
|
||||
)
|
||||
zen_api_key = cast(
|
||||
Optional[str],
|
||||
optional_params.pop("zen_api_key", None) or get_secret_str("WATSONX_ZENAPIKEY"),
|
||||
optional_params.pop("zen_api_key", None)
|
||||
or get_secret_str("WATSONX_ZENAPIKEY"),
|
||||
)
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
@@ -360,5 +374,8 @@ class IBMWatsonXMixin:
|
||||
{}
|
||||
) # Deployment models do not support 'space_id' or 'project_id' in their payload
|
||||
payload["model_id"] = model
|
||||
payload["project_id"] = api_params["project_id"]
|
||||
if api_params["project_id"] is not None:
|
||||
payload["project_id"] = api_params["project_id"]
|
||||
else:
|
||||
payload["space_id"] = api_params["space_id"]
|
||||
return payload
|
||||
|
||||
@@ -228,13 +228,17 @@ class IBMWatsonXAIConfig(IBMWatsonXMixin, BaseConfig):
|
||||
"us-south",
|
||||
]
|
||||
|
||||
def _build_request_payload(self, model: str, prompt: str, optional_params: Dict) -> Dict:
|
||||
def _build_request_payload(
|
||||
self, model: str, prompt: str, optional_params: Dict
|
||||
) -> Dict:
|
||||
"""Shared logic to build request payload"""
|
||||
extra_body_params = optional_params.pop("extra_body", {})
|
||||
optional_params.update(extra_body_params)
|
||||
watsonx_api_params = _get_api_params(params=optional_params)
|
||||
watsonx_auth_payload = self._prepare_payload(model=model, api_params=watsonx_api_params)
|
||||
|
||||
watsonx_api_params = _get_api_params(params=optional_params, model=model)
|
||||
watsonx_auth_payload = self._prepare_payload(
|
||||
model=model, api_params=watsonx_api_params
|
||||
)
|
||||
|
||||
return {
|
||||
"input": prompt,
|
||||
"moderations": optional_params.pop("moderations", {}),
|
||||
@@ -242,21 +246,43 @@ class IBMWatsonXAIConfig(IBMWatsonXMixin, BaseConfig):
|
||||
**watsonx_auth_payload,
|
||||
}
|
||||
|
||||
async def atransform_request(self, model: str, messages: List[AllMessageValues], optional_params: Dict, litellm_params: Dict, headers: Dict) -> Dict:
|
||||
async def atransform_request(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[AllMessageValues],
|
||||
optional_params: Dict,
|
||||
litellm_params: Dict,
|
||||
headers: Dict,
|
||||
) -> Dict:
|
||||
"""Async version of transform_request"""
|
||||
from litellm.llms.watsonx.common_utils import (
|
||||
aconvert_watsonx_messages_to_prompt,
|
||||
)
|
||||
|
||||
|
||||
provider = model.split("/")[0]
|
||||
prompt = await aconvert_watsonx_messages_to_prompt(model=model, messages=messages, provider=provider, custom_prompt_dict={})
|
||||
return self._build_request_payload(model=model, prompt=prompt, optional_params=optional_params)
|
||||
|
||||
def transform_request(self, model: str, messages: List[AllMessageValues], optional_params: Dict, litellm_params: Dict, headers: Dict) -> Dict:
|
||||
prompt = await aconvert_watsonx_messages_to_prompt(
|
||||
model=model, messages=messages, provider=provider, custom_prompt_dict={}
|
||||
)
|
||||
return self._build_request_payload(
|
||||
model=model, prompt=prompt, optional_params=optional_params
|
||||
)
|
||||
|
||||
def transform_request(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[AllMessageValues],
|
||||
optional_params: Dict,
|
||||
litellm_params: Dict,
|
||||
headers: Dict,
|
||||
) -> Dict:
|
||||
"""Sync version of transform_request"""
|
||||
provider = model.split("/")[0]
|
||||
prompt = convert_watsonx_messages_to_prompt(model=model, messages=messages, provider=provider, custom_prompt_dict={})
|
||||
return self._build_request_payload(model=model, prompt=prompt, optional_params=optional_params)
|
||||
prompt = convert_watsonx_messages_to_prompt(
|
||||
model=model, messages=messages, provider=provider, custom_prompt_dict={}
|
||||
)
|
||||
return self._build_request_payload(
|
||||
model=model, prompt=prompt, optional_params=optional_params
|
||||
)
|
||||
|
||||
def transform_response(
|
||||
self,
|
||||
|
||||
@@ -37,7 +37,7 @@ class IBMWatsonXEmbeddingConfig(IBMWatsonXMixin, BaseEmbeddingConfig):
|
||||
optional_params: dict,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
watsonx_api_params = _get_api_params(params=optional_params)
|
||||
watsonx_api_params = _get_api_params(params=optional_params, model=model)
|
||||
watsonx_auth_payload = self._prepare_payload(
|
||||
model=model,
|
||||
api_params=watsonx_api_params,
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
|
||||
class WatsonXAPIParams(TypedDict):
|
||||
project_id: str
|
||||
project_id: Optional[str]
|
||||
space_id: Optional[str]
|
||||
region_name: Optional[str]
|
||||
|
||||
@@ -19,7 +19,7 @@ class WatsonXCredentials(TypedDict):
|
||||
class WatsonXAudioTranscriptionRequestBody(TypedDict):
|
||||
"""
|
||||
WatsonX Audio Transcription API request body.
|
||||
|
||||
|
||||
Follows multipart/form-data format for WatsonX Whisper models.
|
||||
See: https://cloud.ibm.com/apidocs/watsonx-ai
|
||||
"""
|
||||
@@ -27,8 +27,11 @@ class WatsonXAudioTranscriptionRequestBody(TypedDict):
|
||||
model: str
|
||||
"""Model name (e.g., 'whisper-large-v3-turbo')"""
|
||||
|
||||
project_id: str
|
||||
"""WatsonX project ID (required)"""
|
||||
project_id: NotRequired[str]
|
||||
"""WatsonX project ID (optional)"""
|
||||
|
||||
space_id: NotRequired[str]
|
||||
"""WatsonX space ID (optional)"""
|
||||
|
||||
language: NotRequired[str]
|
||||
"""Language code (e.g., 'en', 'es')"""
|
||||
@@ -64,6 +67,7 @@ class WatsonXAIEndpoint(str, Enum):
|
||||
|
||||
class WatsonXModelPattern(str, Enum):
|
||||
"""Model identifier patterns for WatsonX models"""
|
||||
|
||||
GRANITE_CHAT = "granite-chat"
|
||||
IBM_MISTRAL = "ibm-mistral"
|
||||
IBM_MISTRALAI = "ibm-mistralai"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../..")
|
||||
) # Adds the parent directory to the system path
|
||||
import litellm
|
||||
from litellm import completion, embedding
|
||||
from litellm.llms.watsonx.common_utils import IBMWatsonXMixin
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler, AsyncHTTPHandler
|
||||
from unittest.mock import patch, MagicMock, AsyncMock, Mock
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
from unittest.mock import patch, Mock
|
||||
import pytest
|
||||
from typing import Optional
|
||||
|
||||
@@ -188,6 +185,27 @@ def test_watsonx_chat_completions_endpoint(watsonx_chat_completion_call):
|
||||
assert "deployment" not in mock_post.call_args.kwargs["url"]
|
||||
|
||||
|
||||
def test_watsonx_chat_completions_endpoint_space_id(
|
||||
monkeypatch, watsonx_chat_completion_call
|
||||
):
|
||||
my_fake_space_id = "xxx-xxx-xxx-xxx-xxx"
|
||||
monkeypatch.setenv("WATSONX_SPACE_ID", my_fake_space_id)
|
||||
|
||||
monkeypatch.delenv("WATSONX_PROJECT_ID", raising=False)
|
||||
|
||||
model = "watsonx/another-model"
|
||||
messages = [{"role": "user", "content": "Test message"}]
|
||||
|
||||
mock_post, _ = watsonx_chat_completion_call(model=model, messages=messages)
|
||||
|
||||
assert mock_post.call_count == 1
|
||||
assert "deployment" not in mock_post.call_args.kwargs["url"]
|
||||
|
||||
json_data = json.loads(mock_post.call_args.kwargs["data"])
|
||||
assert my_fake_space_id == json_data["space_id"]
|
||||
assert not json_data.get("project_id")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
@@ -209,6 +227,27 @@ def test_watsonx_deployment_space_id(monkeypatch, watsonx_chat_completion_call,
|
||||
assert my_fake_space_id not in json_data
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"watsonx/deployment/<xxxx.xxx.xxx.xxxx>",
|
||||
"watsonx_text/deployment/<xxxx.xxx.xxx.xxxx>",
|
||||
],
|
||||
)
|
||||
def test_watsonx_deployment(watsonx_chat_completion_call, model):
|
||||
messages = [{"content": "Hello, how are you?", "role": "user"}]
|
||||
mock_post, _ = watsonx_chat_completion_call(
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
assert mock_post.call_count == 1
|
||||
json_data = json.loads(mock_post.call_args.kwargs["data"])
|
||||
|
||||
# nor space_id or project_id is required by wx.ai API when inferencing deployment
|
||||
assert "project_id" not in json_data and "space_id" not in json_data
|
||||
|
||||
|
||||
def test_watsonx_deployment_space_id_embedding(monkeypatch, watsonx_embedding_call):
|
||||
my_fake_space_id = "xxx-xxx-xxx-xxx-xxx"
|
||||
monkeypatch.setenv("WATSONX_SPACE_ID", my_fake_space_id)
|
||||
@@ -217,4 +256,6 @@ def test_watsonx_deployment_space_id_embedding(monkeypatch, watsonx_embedding_ca
|
||||
|
||||
assert mock_post.call_count == 1
|
||||
json_data = json.loads(mock_post.call_args.kwargs["data"])
|
||||
assert my_fake_space_id not in json_data
|
||||
|
||||
# nor space_id or project_id is required by wx.ai API when inferencing deployment
|
||||
assert "project_id" not in json_data and "space_id" not in json_data
|
||||
|
||||
+107
-31
@@ -35,7 +35,7 @@ class TestWatsonXAudioTranscription:
|
||||
captured_request["headers"] = kwargs.get("headers", {})
|
||||
captured_request["data"] = kwargs.get("data", {})
|
||||
captured_request["files"] = kwargs.get("files", {})
|
||||
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"text": "test transcription",
|
||||
@@ -44,7 +44,10 @@ class TestWatsonXAudioTranscription:
|
||||
mock_response.status_code = 200
|
||||
return mock_response
|
||||
|
||||
with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new=mock_post):
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new=mock_post,
|
||||
):
|
||||
try:
|
||||
await litellm.atranscription(
|
||||
model="watsonx/whisper-large-v3-turbo",
|
||||
@@ -65,14 +68,16 @@ class TestWatsonXAudioTranscription:
|
||||
|
||||
# Validate headers contain WatsonX auth
|
||||
assert "Authorization" in captured_request["headers"]
|
||||
assert "Bearer test-bearer-token" in captured_request["headers"]["Authorization"]
|
||||
|
||||
assert (
|
||||
"Bearer test-bearer-token" in captured_request["headers"]["Authorization"]
|
||||
)
|
||||
|
||||
# Validate Content-Type is NOT set (httpx sets multipart/form-data automatically)
|
||||
assert "Content-Type" not in captured_request["headers"]
|
||||
|
||||
|
||||
# Validate project_id is in form data, not URL
|
||||
assert captured_request["data"].get("project_id") == "test-project-123"
|
||||
|
||||
|
||||
# Validate file is in files dict
|
||||
assert "file" in captured_request["files"]
|
||||
|
||||
@@ -80,7 +85,7 @@ class TestWatsonXAudioTranscription:
|
||||
async def test_watsonx_transcription_request_body(self):
|
||||
"""
|
||||
Test that litellm.transcription sends correct request body for WatsonX.
|
||||
|
||||
|
||||
Validates that:
|
||||
- Request uses multipart/form-data (data + files)
|
||||
- Model name has watsonx/ prefix removed
|
||||
@@ -93,7 +98,7 @@ class TestWatsonXAudioTranscription:
|
||||
async def mock_post(*args, **kwargs):
|
||||
captured_request["data"] = kwargs.get("data", {})
|
||||
captured_request["files"] = kwargs.get("files", {})
|
||||
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"text": "test transcription",
|
||||
@@ -102,7 +107,10 @@ class TestWatsonXAudioTranscription:
|
||||
mock_response.status_code = 200
|
||||
return mock_response
|
||||
|
||||
with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new=mock_post):
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new=mock_post,
|
||||
):
|
||||
try:
|
||||
await litellm.atranscription(
|
||||
model="watsonx/whisper-large-v3-turbo",
|
||||
@@ -122,29 +130,31 @@ class TestWatsonXAudioTranscription:
|
||||
|
||||
print("JSON DUMPS captured_request:")
|
||||
print(json.dumps(captured_request, indent=4, default=str))
|
||||
|
||||
|
||||
# Model name should NOT have watsonx/ prefix
|
||||
assert data.get("model") == "whisper-large-v3-turbo"
|
||||
|
||||
|
||||
# project_id should be in form data
|
||||
assert data.get("project_id") == "test-project-123"
|
||||
|
||||
|
||||
# OpenAI params should be in form data
|
||||
assert data.get("language") == "en"
|
||||
assert data.get("temperature") == 0.5
|
||||
# response_format should NOT be set by default - only send what user specifies
|
||||
assert "response_format" not in data
|
||||
|
||||
|
||||
# Validate file is in files dict (multipart/form-data)
|
||||
files = captured_request.get("files", {})
|
||||
assert "file" in files
|
||||
assert isinstance(files["file"], tuple) # Should be (filename, content, content_type)
|
||||
assert isinstance(
|
||||
files["file"], tuple
|
||||
) # Should be (filename, content, content_type)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watsonx_transcription_only_user_params_sent(self):
|
||||
async def test_watsonx_transcription_only_user_params_sent_with_project_id(self):
|
||||
"""
|
||||
Test that only user-specified params are sent in request body to WatsonX.
|
||||
|
||||
|
||||
LiteLLM should NOT add extra params like response_format if user didn't specify them.
|
||||
"""
|
||||
captured_request = {}
|
||||
@@ -152,7 +162,7 @@ class TestWatsonXAudioTranscription:
|
||||
async def mock_post(*args, **kwargs):
|
||||
captured_request["data"] = kwargs.get("data", {})
|
||||
captured_request["files"] = kwargs.get("files", {})
|
||||
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"text": "test transcription",
|
||||
@@ -161,7 +171,10 @@ class TestWatsonXAudioTranscription:
|
||||
mock_response.status_code = 200
|
||||
return mock_response
|
||||
|
||||
with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new=mock_post):
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new=mock_post,
|
||||
):
|
||||
try:
|
||||
# Minimal request - only required params
|
||||
await litellm.atranscription(
|
||||
@@ -176,20 +189,81 @@ class TestWatsonXAudioTranscription:
|
||||
pass # We just want to capture the request
|
||||
|
||||
data = captured_request.get("data", {})
|
||||
|
||||
|
||||
# These are the ONLY keys that should be in data
|
||||
expected_keys = {"model", "project_id"}
|
||||
actual_keys = set(data.keys())
|
||||
|
||||
|
||||
assert actual_keys == expected_keys, (
|
||||
f"Request body should only contain {expected_keys}, "
|
||||
f"but got {actual_keys}. "
|
||||
f"Extra keys: {actual_keys - expected_keys}"
|
||||
)
|
||||
|
||||
|
||||
# Specifically verify response_format is NOT added
|
||||
assert "response_format" not in data, "response_format should NOT be added by default"
|
||||
|
||||
assert (
|
||||
"response_format" not in data
|
||||
), "response_format should NOT be added by default"
|
||||
|
||||
# Verify file is sent separately
|
||||
files = captured_request.get("files", {})
|
||||
assert "file" in files
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watsonx_transcription_only_user_params_sent_with_space_id(self):
|
||||
"""
|
||||
Test that only user-specified params are sent in request body to WatsonX.
|
||||
|
||||
LiteLLM should NOT add extra params like response_format if user didn't specify them.
|
||||
"""
|
||||
captured_request = {}
|
||||
|
||||
async def mock_post(*args, **kwargs):
|
||||
captured_request["data"] = kwargs.get("data", {})
|
||||
captured_request["files"] = kwargs.get("files", {})
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"text": "test transcription",
|
||||
"duration": 1.0,
|
||||
}
|
||||
mock_response.status_code = 200
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new=mock_post,
|
||||
):
|
||||
try:
|
||||
# Minimal request - only required params
|
||||
await litellm.atranscription(
|
||||
model="watsonx/whisper-large-v3-turbo",
|
||||
file=b"fake_audio_data",
|
||||
api_base="https://us-south.ml.cloud.ibm.com",
|
||||
api_key="test-api-key",
|
||||
space_id="test-space_id-123",
|
||||
token="test-bearer-token",
|
||||
)
|
||||
except Exception:
|
||||
pass # We just want to capture the request
|
||||
|
||||
data = captured_request.get("data", {})
|
||||
|
||||
# These are the ONLY keys that should be in data
|
||||
expected_keys = {"model", "space_id"}
|
||||
actual_keys = set(data.keys())
|
||||
|
||||
assert actual_keys == expected_keys, (
|
||||
f"Request body should only contain {expected_keys}, "
|
||||
f"but got {actual_keys}. "
|
||||
f"Extra keys: {actual_keys - expected_keys}"
|
||||
)
|
||||
|
||||
# Specifically verify response_format is NOT added
|
||||
assert (
|
||||
"response_format" not in data
|
||||
), "response_format should NOT be added by default"
|
||||
|
||||
# Verify file is sent separately
|
||||
files = captured_request.get("files", {})
|
||||
assert "file" in files
|
||||
@@ -198,7 +272,7 @@ class TestWatsonXAudioTranscription:
|
||||
"""
|
||||
Test that transform_audio_transcription_response removes the 'model' field
|
||||
from WatsonX response before creating TranscriptionResponse.
|
||||
|
||||
|
||||
This test ensures that when WatsonX returns a response with a 'model' field,
|
||||
it is removed before creating the TranscriptionResponse object, since
|
||||
TranscriptionResponse doesn't accept a 'model' parameter.
|
||||
@@ -219,13 +293,13 @@ class TestWatsonXAudioTranscription:
|
||||
|
||||
# Verify the result is a TranscriptionResponse
|
||||
assert isinstance(result, TranscriptionResponse)
|
||||
|
||||
|
||||
# Verify the text is correct
|
||||
assert result.text == "Hello, this is a test transcription."
|
||||
|
||||
|
||||
# Verify duration is set via dictionary assignment
|
||||
assert result["duration"] == 5.5
|
||||
|
||||
|
||||
# Verify the model field is NOT in the serialized result
|
||||
# Check via model_dump() or dict() to ensure it's not in the output
|
||||
try:
|
||||
@@ -233,7 +307,7 @@ class TestWatsonXAudioTranscription:
|
||||
except AttributeError:
|
||||
# Fallback for pydantic v1
|
||||
result_dict = result.dict()
|
||||
|
||||
|
||||
# The 'model' field should not be in the result
|
||||
assert "model" not in result_dict, "Model field should be removed from response"
|
||||
|
||||
@@ -250,15 +324,17 @@ class TestWatsonXAudioTranscription:
|
||||
"text": "Hello, this is a test transcription.",
|
||||
"duration": 5.5,
|
||||
}
|
||||
mock_response.text = '{"text": "Hello, this is a test transcription.", "duration": 5.5}'
|
||||
mock_response.text = (
|
||||
'{"text": "Hello, this is a test transcription.", "duration": 5.5}'
|
||||
)
|
||||
|
||||
result = handler.transform_audio_transcription_response(mock_response)
|
||||
|
||||
# Verify the result is a TranscriptionResponse
|
||||
assert isinstance(result, TranscriptionResponse)
|
||||
|
||||
|
||||
# Verify the text is correct
|
||||
assert result.text == "Hello, this is a test transcription."
|
||||
|
||||
|
||||
# Verify duration is set via dictionary assignment
|
||||
assert result["duration"] == 5.5
|
||||
|
||||
Reference in New Issue
Block a user