Merge remote-tracking branch 'origin/main' into litellm_migrate_tests_p12

This commit is contained in:
yuneng 2026-09-20 13:44:19 +00:00
commit 69133cc8cf
150 changed files with 229 additions and 1225 deletions

View file

@ -1 +0,0 @@
"""OpenAI Evals API tests"""

View file

@ -1 +0,0 @@
# Test module for OpenAI-like embedding handler

View file

@ -1,337 +0,0 @@
"""
Tests for IBM WatsonX Audio Transcription.
Validates that litellm.transcription transforms requests correctly for WatsonX.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.llms.watsonx.audio_transcription.transformation import (
IBMWatsonXAudioTranscriptionConfig,
)
from litellm.types.utils import TranscriptionResponse
class TestWatsonXAudioTranscription:
"""Tests for WatsonX audio transcription via litellm.transcription."""
@pytest.mark.asyncio
async def test_watsonx_transcription_url_and_headers(self):
"""
Test that litellm.transcription sends request to correct WatsonX URL with proper headers.
"""
captured_request = {}
async def mock_post(*args, **kwargs):
captured_request["url"] = str(kwargs.get("url", args[0] if args else None))
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",
"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:
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",
project_id="test-project-123",
token="test-bearer-token",
)
except Exception:
pass # We just want to capture the request
# Validate URL contains WatsonX audio transcription endpoint
assert "/ml/v1/audio/transcriptions" in captured_request["url"]
assert "version=" in captured_request["url"]
# project_id should NOT be in URL (it should be in form data instead)
assert "project_id=test-project-123" not in captured_request["url"]
# Validate headers contain WatsonX auth
assert "Authorization" in captured_request["headers"]
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"]
@pytest.mark.asyncio
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
- project_id is in form data, not URL
- Audio file is in files dict
- OpenAI params are included in form data
"""
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:
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",
project_id="test-project-123",
token="test-bearer-token",
language="en",
temperature=0.5,
)
except Exception:
pass # We just want to capture the request
# Validate form data contains expected fields
data = captured_request.get("data", {})
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)
@pytest.mark.asyncio
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 = {}
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",
project_id="test-project-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", "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"
# 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
def test_transform_audio_transcription_response_removes_model_field(self):
"""
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.
"""
handler = IBMWatsonXAudioTranscriptionConfig()
# Mock response with 'model' field (as WatsonX may return)
mock_response = MagicMock()
mock_response.json.return_value = {
"text": "Hello, this is a test transcription.",
"model": "whisper-large-v3-turbo", # This field should be removed
"duration": 5.5,
}
mock_response.text = '{"text": "Hello, this is a test transcription.", "model": "whisper-large-v3-turbo", "duration": 5.5}'
# This should not raise a TypeError - model field should be removed
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
# 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:
result_dict = result.model_dump()
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"
def test_transform_audio_transcription_response_without_model_field(self):
"""
Test that transform_audio_transcription_response works correctly
when WatsonX response doesn't include a 'model' field.
"""
handler = IBMWatsonXAudioTranscriptionConfig()
# Mock response without 'model' field
mock_response = MagicMock()
mock_response.json.return_value = {
"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

View file

@ -1,577 +0,0 @@
import json
from typing import Optional
from unittest.mock import Mock, patch
import pytest
import litellm
from litellm import completion
from litellm.llms.custom_httpx.http_handler import HTTPHandler
@pytest.fixture
def watsonx_chat_completion_call():
def _call(
model="watsonx/my-test-model",
messages=None,
api_key="test_api_key",
space_id: Optional[str] = None,
headers=None,
client=None,
patch_token_call=True,
):
if messages is None:
messages = [{"role": "user", "content": "Hello, how are you?"}]
if client is None:
client = HTTPHandler()
if patch_token_call:
mock_response = Mock()
mock_response.json.return_value = {
"access_token": "mock_access_token",
"expires_in": 3600,
}
mock_response.raise_for_status = Mock() # No-op to simulate no exception
with (
patch.object(client, "post") as mock_post,
patch.object(
litellm.module_level_client, "post", return_value=mock_response
) as mock_get,
):
try:
completion(
model=model,
messages=messages,
api_key=api_key,
headers=headers or {},
client=client,
space_id=space_id,
)
except Exception as e:
print(e)
return mock_post, mock_get
else:
with patch.object(client, "post") as mock_post:
try:
completion(
model=model,
messages=messages,
api_key=api_key,
headers=headers or {},
client=client,
space_id=space_id,
)
except Exception as e:
print(e)
return mock_post, None
return _call
def test_watsonx_deployment_model_id_not_in_payload(
monkeypatch, watsonx_chat_completion_call
):
"""Test that deployment models do not include 'model_id' in the request payload"""
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai")
model = "watsonx/deployment/test-deployment-id"
messages = [{"role": "user", "content": "Test message"}]
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"])
# Ensure model_id is not in the payload for deployment models
assert "model_id" not in json_data or json_data["model_id"] is None
# Ensure project_id is also not in the payload for deployment models
assert "project_id" not in json_data or json_data["project_id"] is None
def test_watsonx_regular_model_includes_model_id(
monkeypatch, watsonx_chat_completion_call
):
"""Test that regular models include 'model_id' in the request payload"""
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai")
model = "watsonx/regular-model"
messages = [{"role": "user", "content": "Test message"}]
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"])
# Ensure model_id is included in the payload for regular models
assert "model_id" in json_data
assert json_data["model_id"] == "regular-model" # Provider prefix is stripped
# Ensure project_id is also included for regular models
assert "project_id" in json_data
@pytest.fixture
def watsonx_completion_call():
def _call(
model="watsonx_text/my-test-model",
prompt="Hello, how are you?",
api_key="test_api_key",
space_id: Optional[str] = None,
headers=None,
client=None,
patch_token_call=True,
):
if client is None:
client = HTTPHandler()
if patch_token_call:
mock_response = Mock()
mock_response.json.return_value = {
"access_token": "mock_access_token",
"expires_in": 3600,
}
mock_response.raise_for_status = Mock()
with (
patch.object(client, "post") as mock_post,
patch.object(
litellm.module_level_client, "post", return_value=mock_response
) as mock_get,
):
try:
litellm.text_completion(
model=model,
prompt=prompt,
api_key=api_key,
headers=headers or {},
client=client,
space_id=space_id,
)
except Exception as e:
print(e)
return mock_post, mock_get
else:
with patch.object(client, "post") as mock_post:
try:
litellm.text_completion(
model=model,
prompt=prompt,
api_key=api_key,
headers=headers or {},
client=client,
space_id=space_id,
)
except Exception as e:
print(e)
return mock_post, None
return _call
def test_watsonx_completion_deployment_model_id_not_in_payload(
monkeypatch, watsonx_completion_call
):
"""Test that deployment models do not include 'model_id' in completion request payload"""
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai")
model = "watsonx_text/deployment/test-deployment-id"
prompt = "Test prompt"
mock_post, _ = watsonx_completion_call(model=model, prompt=prompt)
assert mock_post.call_count == 1
json_data = json.loads(mock_post.call_args.kwargs["data"])
# Ensure model_id is not in the payload for deployment models
assert "model_id" not in json_data
# Ensure project_id is also not in the payload for deployment models
assert "project_id" not in json_data
def test_watsonx_completion_regular_model_includes_model_id(
monkeypatch, watsonx_completion_call
):
"""Test that regular models include 'model_id' in completion request payload"""
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai")
model = "watsonx_text/regular-model"
prompt = "Test prompt"
mock_post, _ = watsonx_completion_call(model=model, prompt=prompt)
assert mock_post.call_count == 1
json_data = json.loads(mock_post.call_args.kwargs["data"])
# Ensure model_id is included in the payload for regular models
assert "model_id" in json_data
assert json_data["model_id"] == "regular-model" # Provider prefix is stripped
# Ensure project_id is also included for regular models
assert "project_id" in json_data
def test_watsonx_gpt_oss_prompt_transformation(monkeypatch):
"""
Test that gpt-oss-120b model transforms messages to proper format instead of simple concatenation.
This test calls litellm.completion (sync) and verifies what gets sent in the final POST request body.
Input messages should be transformed using the HuggingFace chat template from openai/gpt-oss-120b,
not just concatenated as "You are chatgpt Hi there".
"""
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai")
# Test with gpt-oss model using watsonx_text provider (text generation endpoint)
model = "watsonx_text/openai/gpt-oss-120b"
# Input messages
messages = [
{"role": "system", "content": "You are chatgpt"},
{"role": "user", "content": "Hi there"},
]
client = HTTPHandler()
# Mock HuggingFace template fetch to make test deterministic and avoid network flakiness.
# The test verifies that prompt transformation occurs (not simple concatenation), not the exact
# HuggingFace template format. Using a mock template that produces the correct format is sufficient.
#
# Mock template that produces gpt-oss-120b-like format.
# Note: This is a simplified version of the actual template. The real template is more complex
# (adds metadata, handles tools, thinking messages, etc.), but this captures the key aspects:
# - Converts system role to developer (matching real template behavior)
# - Uses the same tag structure (<|start|>, <|message|>, <|end|>)
# - Preserves message content
mock_tokenizer_config = {
"status": "success",
"tokenizer": {
"chat_template": "{% for message in messages %}{% if message['role'] == 'system' %}<|start|>developer<|message|>{% else %}<|start|>{{ message['role'] }}<|message|>{% endif %}{{ message['content'] }}<|end|>{% endfor %}",
"bos_token": None,
"eos_token": None,
},
}
# Isolate known_tokenizer_config so parallel tests don't interfere.
# monkeypatch.setitem restores the original value on teardown.
hf_model = "openai/gpt-oss-120b"
monkeypatch.setitem(litellm.known_tokenizer_config, hf_model, mock_tokenizer_config)
# Mock IAM token generation to avoid real HTTP calls.
mock_token_response = Mock()
mock_token_response.json.return_value = {
"access_token": "mock_access_token",
"expires_in": 3600,
}
mock_token_response.raise_for_status = Mock()
with (
patch.object(client, "post") as mock_post,
patch.object(
litellm.module_level_client, "post", return_value=mock_token_response
),
):
try:
completion(
model=model,
messages=messages,
api_key="test_api_key",
client=client,
)
except Exception as e:
print(f"Caught expected exception: {e}")
# Verify the POST was called
assert (
mock_post.call_count == 1
), f"POST should have been called exactly once, got {mock_post.call_count}"
# Get the request body
call_args = mock_post.call_args
assert "data" in call_args.kwargs, "call_args.kwargs should contain 'data'"
json_data = json.loads(call_args.kwargs["data"])
# Verify the transformed input is in the request
assert "input" in json_data, "Request should have 'input' field"
transformed_prompt = json_data["input"]
# Verify it's NOT simple concatenation
simple_concat = "You are chatgpt Hi there"
assert transformed_prompt != simple_concat, (
f"Prompt should not be simple concatenation.\n"
f"Expected: Chat template with <|start|> tags\n"
f"Got: {transformed_prompt}"
)
# Verify it contains proper chat template formatting
assert "<|start|>" in transformed_prompt, "Prompt should contain <|start|> tag"
assert "<|message|>" in transformed_prompt, "Prompt should contain <|message|> tag"
assert "<|end|>" in transformed_prompt, "Prompt should contain <|end|> tag"
assert (
"You are chatgpt" in transformed_prompt
), "Prompt should contain system message content"
assert (
"Hi there" in transformed_prompt
), "Prompt should contain user message content"
@pytest.mark.asyncio
@pytest.mark.xdist_group("watsonx_heavy")
async def test_watsonx_gpt_oss_uses_async_http_handler():
"""
Test that verifies async HTTP client is used when fetching HuggingFace templates.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.litellm_core_utils.prompt_templates.huggingface_template_handler import (
_aget_chat_template_file,
)
# Mock the async HTTP client
mock_async_client = MagicMock()
mock_get = AsyncMock()
mock_async_client.get = mock_get
# Create mock response for chat template file
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b"test template content"
mock_get.return_value = mock_response
# Test the async function directly
with patch(
"litellm.litellm_core_utils.prompt_templates.huggingface_template_handler.get_async_httpx_client",
return_value=mock_async_client,
):
result = await _aget_chat_template_file(hf_model_name="test/model")
# Verify async HTTP client was called
assert mock_get.called, "Async HTTP client's get method should be called"
assert mock_get.await_count > 0, "Async HTTP client's get should be awaited"
# Verify it was called with HuggingFace URL
call_args = mock_get.call_args
assert call_args is not None, "get should have been called with arguments"
called_url = call_args.kwargs.get("url", "")
assert (
"huggingface.co/test/model" in called_url
), f"Should call HuggingFace API for test/model, got: {called_url}"
assert result["status"] == "success", "Should return success status"
@pytest.mark.parametrize("tokenizer_config_cached", [False, True], ids=["tokenizer_config", "cached_config_jinja"])
async def test_watsonx_text_gpt_oss_async_completion_fetches_hf_template_off_the_event_loop(
monkeypatch, tokenizer_config_cached
):
import httpx
from litellm._uuid import uuid
from litellm.litellm_core_utils.prompt_templates import huggingface_template_handler
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
hf_model = f"openai/gpt-oss-{uuid.uuid4()}"
chat_template = "{% for m in messages %}<|{{ m['role'] }}|>{{ m['content'] }}{% endfor %}"
if tokenizer_config_cached:
cached_config = {"status": "success", "tokenizer": {"bos_token": None, "eos_token": None}}
monkeypatch.setattr(litellm, "known_tokenizer_config", {hf_model: cached_config})
expected_fetch = f"https://huggingface.co/{hf_model}/raw/main/chat_template.jinja"
else:
monkeypatch.setattr(litellm, "known_tokenizer_config", {})
expected_fetch = f"https://huggingface.co/{hf_model}/raw/main/tokenizer_config.json"
hf_fetched = []
captured = {}
def forbid_sync_client():
raise AssertionError("sync HuggingFace fetch ran on the request path")
async def serve_hf_file(url, **kwargs):
hf_fetched.append(url)
if url.endswith(".jinja"):
return httpx.Response(200, content=chat_template.encode())
return httpx.Response(200, json={"chat_template": chat_template, "bos_token": None, "eos_token": None})
monkeypatch.setattr(huggingface_template_handler, "_get_httpx_client", forbid_sync_client)
monkeypatch.setattr(huggingface_template_handler, "get_async_httpx_client", lambda **kwargs: Mock(get=serve_hf_file))
def handle(request):
captured["body"] = json.loads(request.content)
return httpx.Response(
200,
json={
"model_id": hf_model,
"results": [
{
"generated_text": "Hi",
"generated_token_count": 1,
"input_token_count": 1,
"stop_reason": "eos_token",
}
],
},
)
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
response = await litellm.acompletion(
model=f"watsonx_text/{hf_model}",
messages=[{"role": "user", "content": "Hi there"}],
api_base="https://test-api.watsonx.ai",
project_id="test-project-id",
token="test-token",
client=client,
)
assert response.choices[0].message.content == "Hi"
assert hf_fetched == [expected_fetch]
assert captured["body"]["input"] == "<|user|>Hi there"
def test_watsonx_chat_completion_with_reasoning_effort(monkeypatch):
"""
Test that 'reasoning_effort' is correctly passed through to the WatsonX API payload.
"""
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai")
model = "watsonx/openai/gpt-oss-120b"
messages = [{"role": "user", "content": "Test message"}]
client = HTTPHandler()
# Mock the token generation call
mock_token_response = Mock()
mock_token_response.json.return_value = {
"access_token": "mock_access_token",
"expires_in": 3600,
}
mock_token_response.raise_for_status = Mock()
# Call litellm.completion with the new parameter
with (
patch.object(client, "post") as mock_post,
patch.object(
litellm.module_level_client, "post", return_value=mock_token_response
),
):
try:
completion(
model=model,
messages=messages,
api_key="test_api_key",
client=client,
reasoning_effort="low",
)
except Exception as e:
print(f"Caught expected exception: {e}")
# Verify the parameter is in the final request payload
assert (
mock_post.call_count == 1
), "The completion endpoint should have been called once."
# Get the JSON data sent in the POST request
request_kwargs = mock_post.call_args.kwargs
json_data = json.loads(request_kwargs["data"])
print("\nRequest payload sent to WatsonX API:")
print(json.dumps(json_data, indent=2))
# Check for the parameter at the top level of the payload
assert (
"reasoning_effort" in json_data
), "'reasoning_effort' should be at the top level of the payload."
assert (
json_data["reasoning_effort"] == "low"
), "The value of 'reasoning_effort' should be 'low'."
def test_watsonx_zen_api_key_from_client(monkeypatch, watsonx_chat_completion_call):
"""
Test that zen_api_key can be passed from client code and is used in Authorization header.
"""
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai")
model = "watsonx/ibm/granite-3-3-8b-instruct"
messages = [{"role": "user", "content": "What is your favorite color?"}]
client = HTTPHandler()
zen_api_key = "U1ZDLWQo="
# No need to patch token call since zen_api_key should skip token generation
with patch.object(client, "post") as mock_post:
try:
completion(
model=model,
messages=messages,
api_key="test_api_key",
client=client,
zen_api_key=zen_api_key,
)
except Exception as e:
print(f"Caught expected exception: {e}")
# Verify the request was made
assert (
mock_post.call_count == 1
), "The completion endpoint should have been called once."
# Get the headers sent in the POST request
request_kwargs = mock_post.call_args.kwargs
headers = request_kwargs["headers"]
print("\nHeaders sent to WatsonX API:")
print(json.dumps(dict(headers), indent=2))
# Verify Authorization header uses ZenApiKey format
assert "Authorization" in headers, "Authorization header should be present."
assert headers["Authorization"] == f"ZenApiKey {zen_api_key}", (
f"Authorization header should use ZenApiKey format. "
f"Expected: 'ZenApiKey {zen_api_key}', Got: '{headers['Authorization']}'"
)
def test_watsonx_zen_api_key_from_env(monkeypatch, watsonx_chat_completion_call):
"""
Test that zen_api_key from environment variable is used in Authorization header.
"""
monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id")
monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai")
zen_api_key = "U1ZDLWxpdG--==="
monkeypatch.setenv("WATSONX_ZENAPIKEY", zen_api_key)
model = "watsonx/ibm/granite-3-3-8b-instruct"
messages = [{"role": "user", "content": "What is your favorite color?"}]
client = HTTPHandler()
# No need to patch token call since zen_api_key should skip token generation
with patch.object(client, "post") as mock_post:
try:
completion(
model=model,
messages=messages,
api_key="test_api_key",
client=client,
)
except Exception as e:
print(f"Caught expected exception: {e}")
# Verify the request was made
assert (
mock_post.call_count == 1
), "The completion endpoint should have been called once."
# Get the headers sent in the POST request
request_kwargs = mock_post.call_args.kwargs
headers = request_kwargs["headers"]
print("\nHeaders sent to WatsonX API:")
print(json.dumps(dict(headers), indent=2))
# Verify Authorization header uses ZenApiKey format
assert "Authorization" in headers, "Authorization header should be present."
assert headers["Authorization"] == f"ZenApiKey {zen_api_key}", (
f"Authorization header should use ZenApiKey format. "
f"Expected: 'ZenApiKey {zen_api_key}', Got: '{headers['Authorization']}'"
)

View file

@ -1 +0,0 @@
# XAI Responses API tests

View file

@ -1,105 +0,0 @@
"""
Tests for XAI Responses API transformation
Tests the XAIResponsesAPIConfig class that handles XAI-specific
transformations for the Responses API.
Source: litellm/llms/xai/responses/transformation.py
"""
import pytest
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
from litellm.llms.xai.responses.transformation import XAIResponsesAPIConfig
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
class TestXAIResponsesAPITransformation:
"""Test XAI Responses API configuration and transformations"""
def test_xai_provider_config_registration(self):
"""Test that XAI provider returns XAIResponsesAPIConfig"""
config = ProviderConfigManager.get_provider_responses_api_config(
model="xai/grok-4-fast",
provider=LlmProviders.XAI,
)
assert config is not None, "Config should not be None for XAI provider"
assert isinstance(
config, XAIResponsesAPIConfig
), f"Expected XAIResponsesAPIConfig, got {type(config)}"
assert (
config.custom_llm_provider == LlmProviders.XAI
), "custom_llm_provider should be XAI"
def test_code_interpreter_container_field_removed(self):
"""Test that container field is removed from code_interpreter tools"""
config = XAIResponsesAPIConfig()
params = ResponsesAPIOptionalRequestParams(
tools=[{"type": "code_interpreter", "container": {"type": "auto"}}]
)
result = config.map_openai_params(
response_api_optional_params=params, model="grok-4-fast", drop_params=False
)
assert "tools" in result
assert len(result["tools"]) == 1
assert result["tools"][0]["type"] == "code_interpreter"
assert (
"container" not in result["tools"][0]
), "Container field should be removed"
def test_instructions_parameter_forwarded(self):
"""xAI supports 'instructions' on /v1/responses, so it must survive param mapping"""
config = XAIResponsesAPIConfig()
params = ResponsesAPIOptionalRequestParams(
instructions="You are a helpful assistant.", temperature=0.7
)
result = config.map_openai_params(
response_api_optional_params=params, model="grok-4-fast", drop_params=False
)
assert result.get("instructions") == "You are a helpful assistant."
assert result.get("temperature") == 0.7, "Other params should be preserved"
def test_supported_params_includes_instructions(self):
"""A system message bridged to 'instructions' must not be rejected for xAI"""
config = XAIResponsesAPIConfig()
supported = config.get_supported_openai_params("grok-4-fast")
assert "instructions" in supported, "instructions should be supported"
assert "tools" in supported, "tools should be supported"
assert "temperature" in supported, "temperature should be supported"
assert "model" in supported, "model should be supported"
def test_xai_responses_endpoint_url(self):
"""Test that get_complete_url returns correct XAI endpoint"""
config = XAIResponsesAPIConfig()
# Test with default XAI API base
url = config.get_complete_url(api_base=None, litellm_params={})
assert (
url == "https://api.x.ai/v1/responses"
), f"Expected XAI responses endpoint, got {url}"
# Test with custom api_base
custom_url = config.get_complete_url(
api_base="https://custom.x.ai/v1", litellm_params={}
)
assert (
custom_url == "https://custom.x.ai/v1/responses"
), f"Expected custom endpoint, got {custom_url}"
# Test with trailing slash
url_with_slash = config.get_complete_url(
api_base="https://api.x.ai/v1/", litellm_params={}
)
assert (
url_with_slash == "https://api.x.ai/v1/responses"
), "Should handle trailing slash"

View file

@ -1,4 +1,5 @@
import os
from collections.abc import Iterator
from typing import Final
import litellm
@ -7,7 +8,19 @@ from pytest_socket import enable_socket, socket_allow_hosts
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
import litellm # noqa: E402 # litellm reads LITELLM_LOCAL_MODEL_COST_MAP at import
import litellm.router as litellm_router_module # noqa: E402 # same import-time dependency
import litellm.utils as litellm_utils_module # noqa: E402 # same import-time dependency
LOOPBACK_HOSTS: Final = ["127.0.0.1", "::1"]
AMBIENT_AZURE_CREDENTIAL_ENV_VARS: Final = (
"AZURE_AD_TOKEN",
"AZURE_TENANT_ID",
"AZURE_CLIENT_ID",
"AZURE_CLIENT_SECRET",
"AZURE_USERNAME",
"AZURE_PASSWORD",
)
@pytest.fixture
@ -35,5 +48,38 @@ def pytest_runtest_setup() -> None:
_allow_loopback_only()
@pytest.fixture(autouse=True)
def isolate_router_model_cost_state() -> Iterator[None]:
original_live_routers: Final = frozenset(litellm_router_module._live_routers)
original_runtime_registered_model_cost: Final = {
model_key: dict(model_value)
for model_key, model_value in litellm_utils_module._runtime_registered_model_cost.items()
}
yield
for router in tuple(litellm_router_module._live_routers):
litellm_router_module._live_routers.discard(router)
for router in original_live_routers:
litellm_router_module._live_routers.add(router)
litellm_utils_module._runtime_registered_model_cost.clear()
litellm_utils_module._runtime_registered_model_cost.update(original_runtime_registered_model_cost)
litellm_utils_module._invalidate_model_cost_lowercase_map()
litellm.get_model_info.cache_clear()
@pytest.fixture
def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
litellm.get_model_info.cache_clear()
yield
litellm.get_model_info.cache_clear()
@pytest.fixture
def no_ambient_azure_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
for name in AMBIENT_AZURE_CREDENTIAL_ENV_VARS:
monkeypatch.delenv(name, raising=False)
def pytest_sessionfinish() -> None:
enable_socket()

View file

View file

View file

@ -133,88 +133,6 @@ def test_azure_image_generation_flattens_extra_body():
assert data["size"] == "1024x1024"
def test_azure_image_generation_creates_token_provider_from_credentials():
"""
Test that azure_ad_token_provider is created from tenant_id, client_id, client_secret.
This test verifies the fix in images/main.py where we now create the
azure_ad_token_provider from credentials in litellm_params if it's not already provided.
"""
# Simulate the fix in images/main.py
litellm_params_dict = {
"tenant_id": "test-tenant-id",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"azure_scope": None,
}
azure_ad_token_provider = None
# This is the logic we added in images/main.py
if azure_ad_token_provider is None:
tenant_id = litellm_params_dict.get("tenant_id")
client_id = litellm_params_dict.get("client_id")
client_secret = litellm_params_dict.get("client_secret")
azure_scope = (
litellm_params_dict.get("azure_scope")
or "https://cognitiveservices.azure.com/.default"
)
# Verify the credentials are extracted correctly
assert tenant_id == "test-tenant-id"
assert client_id == "test-client-id"
assert client_secret == "test-client-secret"
assert azure_scope == "https://cognitiveservices.azure.com/.default"
# Verify the condition to create token provider is met
assert (
tenant_id and client_id and client_secret
), "Credentials should be present to create token provider"
def test_azure_image_generation_headers_without_api_key():
"""
Test that when api_key is None, the api-key header is not added to headers.
This prevents the httpx TypeError: "Header value must be str or bytes, not <class 'NoneType'>"
that was occurring when api_key was None and being set in headers.
This is a unit test for the fix in images/main.py where we now check:
if api_key is not None:
default_headers["api-key"] = api_key
"""
from litellm.images.main import image_generation
# Test the header building logic directly
api_key = None
default_headers = {
"Content-Type": "application/json",
}
# This is the fix: only add api-key if it's not None
if api_key is not None:
default_headers["api-key"] = api_key
# Verify api-key is not in headers when api_key is None
assert "api-key" not in default_headers
# Verify Content-Type is still there
assert default_headers["Content-Type"] == "application/json"
# Test with a valid api_key
api_key = "valid-key-123"
default_headers_with_key = {
"Content-Type": "application/json",
}
if api_key is not None:
default_headers_with_key["api-key"] = api_key
# Verify api-key is added when api_key is valid
assert "api-key" in default_headers_with_key
assert default_headers_with_key["api-key"] == "valid-key-123"
def test_azure_image_generation_drop_params_response_format():
"""
Test that unsupported params like response_format are dropped when drop_params=True.

View file

@ -426,41 +426,6 @@ async def test_async_realtime_beta_without_api_version_raises():
)
@pytest.mark.asyncio
async def test_realtime_protocol_env_var_fallback():
"""
Test that LITELLM_AZURE_REALTIME_PROTOCOL env var is used as fallback.
Fixes #22127: no way to set realtime_protocol from config.
"""
from litellm.realtime_api.main import _arealtime
from litellm.types.router import GenericLiteLLMParams
with patch.dict(os.environ, {"LITELLM_AZURE_REALTIME_PROTOCOL": "v1"}):
# Create a GenericLiteLLMParams without realtime_protocol
litellm_params = GenericLiteLLMParams()
# The env var should be picked up as fallback
realtime_protocol = (
{}.get("realtime_protocol")
or litellm_params.get("realtime_protocol")
or os.environ.get("LITELLM_AZURE_REALTIME_PROTOCOL")
or "beta"
)
assert realtime_protocol == "v1"
@pytest.mark.asyncio
async def test_realtime_protocol_from_litellm_params():
"""
Test that realtime_protocol is read from litellm_params (config.yaml extra field).
Fixes #22127: realtime_protocol in litellm_params was not used.
"""
from litellm.types.router import GenericLiteLLMParams
# Simulate config.yaml with realtime_protocol as an extra field
litellm_params = GenericLiteLLMParams(realtime_protocol="GA")
assert litellm_params.get("realtime_protocol") == "GA"
@pytest.mark.asyncio
async def test_arealtime_transcription_intent_defaults_to_ga(monkeypatch):
"""
@ -742,7 +707,7 @@ async def test_realtime_health_check_uses_bearer_token_when_no_api_key(monkeypat
@pytest.mark.asyncio
async def test_arealtime_forwards_deployment_azure_ad_token(monkeypatch):
async def test_arealtime_forwards_deployment_azure_ad_token(monkeypatch, no_ambient_azure_credentials):
"""
The router binds a deployment's `azure_ad_token` to `_arealtime`'s named parameter rather than
**kwargs, so it must still reach the handler.

View file

View file

View file

@ -352,21 +352,6 @@ def test_azure_model_router_stamps_selected_model_on_hidden_params():
)
def test_azure_model_router_stamp_does_not_leak_across_responses():
"""
ModelResponse declares _hidden_params as a class-level dict, so the stamp has to be written
as a fresh dict. Mutating in place would bleed the selected model into unrelated responses.
"""
from litellm.llms.azure_ai.common_utils import (
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY,
)
from litellm.types.utils import ModelResponse
untouched = ModelResponse()
assert AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY not in (untouched._hidden_params or {})
def test_drop_tool_level_extra_fields_strips_copilot_mcp_server_name():
"""
Regression test: Azure AI returns 400 when tools contain copilot_mcp_server_name.

View file

@ -41,7 +41,7 @@ def test_azure_ai_url_generation():
assert complete_url == expected_url
def test_azure_ai_validate_environment_with_entra_token(monkeypatch):
def test_azure_ai_validate_environment_with_entra_token(monkeypatch, no_ambient_azure_credentials):
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
monkeypatch.setattr(litellm, "api_key", None)
config = AzureFoundryFluxImageEditConfig()
@ -55,7 +55,7 @@ def test_azure_ai_validate_environment_with_entra_token(monkeypatch):
assert headers == {"Authorization": "Bearer entra-token"}
def test_flux2_validate_environment_with_entra_token(monkeypatch):
def test_flux2_validate_environment_with_entra_token(monkeypatch, no_ambient_azure_credentials):
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
monkeypatch.setattr(litellm, "api_key", None)
config = AzureFoundryFlux2ImageEditConfig()

View file

@ -174,7 +174,7 @@ class TestAzureMAIImageEdit:
assert image_response.usage.total_tokens == 1024
def test_mai_validate_environment_with_entra_token(monkeypatch):
def test_mai_validate_environment_with_entra_token(monkeypatch, no_ambient_azure_credentials):
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
monkeypatch.setattr(litellm, "api_key", None)

View file

View file

@ -256,13 +256,13 @@ def test_serverless_host_gets_a_bearer_token():
assert "api-key" not in headers
def test_entra_token_is_used_when_the_deployment_has_no_api_key():
def test_entra_token_is_used_when_the_deployment_has_no_api_key(no_ambient_azure_credentials):
headers = _auth_headers(api_key=None, api_base=FOUNDRY_BASE, litellm_params={"azure_ad_token": "entra-token"})
assert headers["Authorization"] == "Bearer entra-token"
def test_no_credentials_at_all_raises():
def test_no_credentials_at_all_raises(no_ambient_azure_credentials):
with pytest.raises(ValueError, match="Missing Azure AI credentials"):
_auth_headers(api_key=None, api_base=FOUNDRY_BASE)

View file

@ -105,7 +105,7 @@ class TestAzureAIRerankConfigValidateEnvironment:
assert headers["Authorization"] == "Bearer my-key"
def test_falls_back_to_entra_token(self, monkeypatch):
def test_falls_back_to_entra_token(self, monkeypatch, no_ambient_azure_credentials):
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
monkeypatch.setattr(litellm, "azure_key", None)

View file

View file

View file

View file

@ -98,6 +98,8 @@ class TestCountTokensLocationResolution:
self, counter, monkeypatch
):
"""Claude models without any location should default to us-east5."""
monkeypatch.delenv("VERTEXAI_LOCATION", raising=False)
monkeypatch.delenv("VERTEX_LOCATION", raising=False)
captured = {}
async def fake_ensure_access_token(

View file

@ -1,6 +1,21 @@
import pytest
import litellm
@pytest.fixture
def local_model_cost_map(monkeypatch):
original_model_cost = litellm.model_cost
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
litellm.model_cost = litellm.get_model_cost_map(url="")
litellm.get_model_info.cache_clear()
try:
yield
finally:
litellm.model_cost = original_model_cost
litellm.get_model_info.cache_clear()
def test_reasoning_effort_stays_unsupported_on_vertex_partner_models(local_model_cost_map):
assert "reasoning_effort" in litellm.get_supported_openai_params(
model="mistral-medium-3", custom_llm_provider="mistral"

View file

Some files were not shown because too many files have changed in this diff Show more