mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
Merge remote-tracking branch 'origin/main' into litellm_migrate_tests_p12
This commit is contained in:
commit
69133cc8cf
150 changed files with 229 additions and 1225 deletions
|
|
@ -1 +0,0 @@
|
|||
"""OpenAI Evals API tests"""
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Test module for OpenAI-like embedding handler
|
||||
|
|
@ -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
|
||||
|
|
@ -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']}'"
|
||||
)
|
||||
|
|
@ -1 +0,0 @@
|
|||
# XAI Responses API tests
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
0
tests/unit/llms/anthropic/messages/__init__.py
Normal file
0
tests/unit/llms/anthropic/messages/__init__.py
Normal file
0
tests/unit/llms/apiserpent/__init__.py
Normal file
0
tests/unit/llms/apiserpent/__init__.py
Normal file
0
tests/unit/llms/azure/__init__.py
Normal file
0
tests/unit/llms/azure/__init__.py
Normal file
0
tests/unit/llms/azure/image_edit/__init__.py
Normal file
0
tests/unit/llms/azure/image_edit/__init__.py
Normal file
0
tests/unit/llms/azure/image_generation/__init__.py
Normal file
0
tests/unit/llms/azure/image_generation/__init__.py
Normal 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.
|
||||
0
tests/unit/llms/azure/passthrough/__init__.py
Normal file
0
tests/unit/llms/azure/passthrough/__init__.py
Normal file
0
tests/unit/llms/azure/realtime/__init__.py
Normal file
0
tests/unit/llms/azure/realtime/__init__.py
Normal 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.
|
||||
0
tests/unit/llms/azure/response/__init__.py
Normal file
0
tests/unit/llms/azure/response/__init__.py
Normal file
0
tests/unit/llms/azure/search/__init__.py
Normal file
0
tests/unit/llms/azure/search/__init__.py
Normal file
0
tests/unit/llms/azure/text_to_speech/__init__.py
Normal file
0
tests/unit/llms/azure/text_to_speech/__init__.py
Normal file
0
tests/unit/llms/azure/vector_stores/__init__.py
Normal file
0
tests/unit/llms/azure/vector_stores/__init__.py
Normal file
0
tests/unit/llms/azure_ai/__init__.py
Normal file
0
tests/unit/llms/azure_ai/__init__.py
Normal file
0
tests/unit/llms/azure_ai/chat/__init__.py
Normal file
0
tests/unit/llms/azure_ai/chat/__init__.py
Normal 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.
|
||||
0
tests/unit/llms/azure_ai/embed/__init__.py
Normal file
0
tests/unit/llms/azure_ai/embed/__init__.py
Normal file
0
tests/unit/llms/azure_ai/image_edit/__init__.py
Normal file
0
tests/unit/llms/azure_ai/image_edit/__init__.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
0
tests/unit/llms/azure_ai/ocr/__init__.py
Normal file
0
tests/unit/llms/azure_ai/ocr/__init__.py
Normal file
0
tests/unit/llms/azure_ai/passthrough/__init__.py
Normal file
0
tests/unit/llms/azure_ai/passthrough/__init__.py
Normal 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)
|
||||
|
||||
0
tests/unit/llms/azure_ai/rerank/__init__.py
Normal file
0
tests/unit/llms/azure_ai/rerank/__init__.py
Normal 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)
|
||||
|
||||
0
tests/unit/llms/azure_ai/responses/__init__.py
Normal file
0
tests/unit/llms/azure_ai/responses/__init__.py
Normal file
0
tests/unit/llms/openai/embeddings/__init__.py
Normal file
0
tests/unit/llms/openai/embeddings/__init__.py
Normal file
0
tests/unit/llms/openai/evals/__init__.py
Normal file
0
tests/unit/llms/openai/evals/__init__.py
Normal file
0
tests/unit/llms/openai/image_generation/__init__.py
Normal file
0
tests/unit/llms/openai/image_generation/__init__.py
Normal file
0
tests/unit/llms/openai/speech/__init__.py
Normal file
0
tests/unit/llms/openai/speech/__init__.py
Normal file
0
tests/unit/llms/openai/transcriptions/__init__.py
Normal file
0
tests/unit/llms/openai/transcriptions/__init__.py
Normal file
0
tests/unit/llms/openai/vector_store_files/__init__.py
Normal file
0
tests/unit/llms/openai/vector_store_files/__init__.py
Normal file
0
tests/unit/llms/openai/vector_stores/__init__.py
Normal file
0
tests/unit/llms/openai/vector_stores/__init__.py
Normal file
0
tests/unit/llms/openai/videos/__init__.py
Normal file
0
tests/unit/llms/openai/videos/__init__.py
Normal file
0
tests/unit/llms/openai_like/__init__.py
Normal file
0
tests/unit/llms/openai_like/__init__.py
Normal file
0
tests/unit/llms/openai_like/chat/__init__.py
Normal file
0
tests/unit/llms/openai_like/chat/__init__.py
Normal file
0
tests/unit/llms/openai_like/embedding/__init__.py
Normal file
0
tests/unit/llms/openai_like/embedding/__init__.py
Normal file
0
tests/unit/llms/openai_like/messages/__init__.py
Normal file
0
tests/unit/llms/openai_like/messages/__init__.py
Normal file
0
tests/unit/llms/openrouter/chat/__init__.py
Normal file
0
tests/unit/llms/openrouter/chat/__init__.py
Normal file
0
tests/unit/llms/openrouter/image_edit/__init__.py
Normal file
0
tests/unit/llms/openrouter/image_edit/__init__.py
Normal file
0
tests/unit/llms/openrouter/image_generation/__init__.py
Normal file
0
tests/unit/llms/openrouter/image_generation/__init__.py
Normal file
0
tests/unit/llms/vertex_ai/__init__.py
Normal file
0
tests/unit/llms/vertex_ai/__init__.py
Normal 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(
|
||||
|
|
@ -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"
|
||||
0
tests/unit/llms/vertex_ai/videos/__init__.py
Normal file
0
tests/unit/llms/vertex_ai/videos/__init__.py
Normal file
0
tests/unit/llms/volcengine/__init__.py
Normal file
0
tests/unit/llms/volcengine/__init__.py
Normal file
0
tests/unit/llms/volcengine/responses/__init__.py
Normal file
0
tests/unit/llms/volcengine/responses/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue