From 72dc65fbb42d4318d7d80f6aca1f41a17d366ebf Mon Sep 17 00:00:00 2001 From: Mateusz Szewczyk <139469471+MateuszOssGit@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:17:20 +0100 Subject: [PATCH] chore: allow passing scope id for watsonx inferencing (#18959) * chore: allow inference with space * make lint and make format --- .../audio_transcription/transformation.py | 63 ++++---- litellm/llms/watsonx/chat/handler.py | 2 +- litellm/llms/watsonx/chat/transformation.py | 13 -- litellm/llms/watsonx/common_utils.py | 39 +++-- .../llms/watsonx/completion/transformation.py | 50 +++++-- litellm/llms/watsonx/embed/transformation.py | 2 +- litellm/types/llms/watsonx.py | 12 +- tests/llm_translation/test_watsonx.py | 53 ++++++- ...sonx_audio_transcription_transformation.py | 138 ++++++++++++++---- 9 files changed, 267 insertions(+), 105 deletions(-) diff --git a/litellm/llms/watsonx/audio_transcription/transformation.py b/litellm/llms/watsonx/audio_transcription/transformation.py index c7e6a77b96..fd6ac61821 100644 --- a/litellm/llms/watsonx/audio_transcription/transformation.py +++ b/litellm/llms/watsonx/audio_transcription/transformation.py @@ -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 diff --git a/litellm/llms/watsonx/chat/handler.py b/litellm/llms/watsonx/chat/handler.py index bc0effe4a1..40ccc45497 100644 --- a/litellm/llms/watsonx/chat/handler.py +++ b/litellm/llms/watsonx/chat/handler.py @@ -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( diff --git a/litellm/llms/watsonx/chat/transformation.py b/litellm/llms/watsonx/chat/transformation.py index 0bb96673ef..157493a4ce 100644 --- a/litellm/llms/watsonx/chat/transformation.py +++ b/litellm/llms/watsonx/chat/transformation.py @@ -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 diff --git a/litellm/llms/watsonx/common_utils.py b/litellm/llms/watsonx/common_utils.py index 0207020534..774f6dc1f3 100644 --- a/litellm/llms/watsonx/common_utils.py +++ b/litellm/llms/watsonx/common_utils.py @@ -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 diff --git a/litellm/llms/watsonx/completion/transformation.py b/litellm/llms/watsonx/completion/transformation.py index 3c1229ecd2..7180e12162 100644 --- a/litellm/llms/watsonx/completion/transformation.py +++ b/litellm/llms/watsonx/completion/transformation.py @@ -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, diff --git a/litellm/llms/watsonx/embed/transformation.py b/litellm/llms/watsonx/embed/transformation.py index 21f508da01..930212e3ef 100644 --- a/litellm/llms/watsonx/embed/transformation.py +++ b/litellm/llms/watsonx/embed/transformation.py @@ -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, diff --git a/litellm/types/llms/watsonx.py b/litellm/types/llms/watsonx.py index 6c42c3ecea..137090b032 100644 --- a/litellm/types/llms/watsonx.py +++ b/litellm/types/llms/watsonx.py @@ -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" diff --git a/tests/llm_translation/test_watsonx.py b/tests/llm_translation/test_watsonx.py index e0c125a901..d6a82b4496 100644 --- a/tests/llm_translation/test_watsonx.py +++ b/tests/llm_translation/test_watsonx.py @@ -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/", + "watsonx_text/deployment/", + ], +) +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 diff --git a/tests/test_litellm/llms/watsonx/audio_transcription/test_watsonx_audio_transcription_transformation.py b/tests/test_litellm/llms/watsonx/audio_transcription/test_watsonx_audio_transcription_transformation.py index fd5f8f3eff..6ff53287e9 100644 --- a/tests/test_litellm/llms/watsonx/audio_transcription/test_watsonx_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/watsonx/audio_transcription/test_watsonx_audio_transcription_transformation.py @@ -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