mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
* test: move the unit half of 126 mixed legacy files into tests/unit * test: restore litellm globals that moved tests set * test: finalize migration test cleanup * test: restore original bodies of moved legacy tests The move into tests/unit had rewritten 612 test bodies, and some of the rewrites dropped assertions. Each moved test now carries its original body from the legacy file, with only the imports, helpers, fake provider credentials and monkeypatched env it needs to run under tests/unit test_timeout_streaming goes back to tests/local_testing because it needs the fake OpenAI endpoint server. The image payload fixture moves with its only user, and two tests that leaked global state (a registered model cost entry and queued logging tasks) are now isolated * test: drop module imports shadowed by restored local imports * test: assert on LiteLLM output in no-assertion moved tests and isolate leaks Twenty no-assertion candidates get one assertion on the value LiteLLM returns, with the original lines unchanged. Four tests go back to their legacy files because they only check types or imports, write into the working directory, or cannot assert without a body change Two moved tests leaked globals into later tests in the same worker, so monkeypatch fixtures now restore the retry-after header parser and the end user cost tracking flags * test: drain queued logging tasks before the Phoenix span test The moved Phoenix test counted spans from logging tasks that earlier tests had queued, so the drain fixture moves to tests/unit/conftest.py and both it and the Datadog batch test use it. test_factory_function goes back to its legacy file because its returned wrapper calls the real Assistants API and cannot be asserted on without a body change --------- Co-authored-by: yuneng <yuneng@berri.ai>
753 lines
25 KiB
Python
753 lines
25 KiB
Python
import asyncio
|
|
import base64
|
|
import json
|
|
from typing import Any, Dict, Final, List, Optional
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import litellm
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.images.utils import ImageEditRequestUtils
|
|
from litellm.litellm_core_utils.litellm_logging import use_custom_pricing_for_model
|
|
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
|
from litellm.types.images.main import ImageEditOptionalRequestParams
|
|
from litellm.types.utils import StandardLoggingPayload
|
|
from litellm.utils import ImageResponse
|
|
|
|
_TEST_IMAGE_BYTES: Final = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
def _make_test_images() -> list[bytes]:
|
|
return [_TEST_IMAGE_BYTES, _TEST_IMAGE_BYTES]
|
|
|
|
|
|
def _make_single_test_image() -> bytes:
|
|
return _TEST_IMAGE_BYTES
|
|
|
|
|
|
class _ImageEditTestLogger(CustomLogger):
|
|
def __init__(self):
|
|
self.standard_logging_payload: Optional[StandardLoggingPayload] = None
|
|
self.logging_completed = asyncio.Event()
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
self.standard_logging_payload = kwargs.get("standard_logging_object", None)
|
|
self.logging_completed.set()
|
|
|
|
|
|
class MockImageEditConfig(BaseImageEditConfig):
|
|
def get_supported_openai_params(self, model: str) -> List[str]:
|
|
return ["size", "quality"]
|
|
|
|
def map_openai_params(
|
|
self,
|
|
image_edit_optional_params: ImageEditOptionalRequestParams,
|
|
model: str,
|
|
drop_params: bool,
|
|
) -> Dict[str, Any]:
|
|
return dict(image_edit_optional_params)
|
|
|
|
def get_complete_url(self, model: str, api_base: str, litellm_params: dict) -> str:
|
|
return "https://example.com/api"
|
|
|
|
def validate_environment(
|
|
self,
|
|
headers: dict,
|
|
model: str,
|
|
api_key: Optional[str] = None,
|
|
litellm_params: Optional[dict] = None,
|
|
api_base: Optional[str] = None,
|
|
) -> dict:
|
|
return headers
|
|
|
|
def transform_image_edit_request(self, *args, **kwargs):
|
|
return {}, []
|
|
|
|
def transform_image_edit_response(self, *args, **kwargs):
|
|
return MagicMock()
|
|
|
|
|
|
class TestImageEditRequestUtilsDropParams:
|
|
def setup_method(self):
|
|
self.config = MockImageEditConfig()
|
|
self.model = "test-model"
|
|
self._original_drop_params = getattr(litellm, "drop_params", None)
|
|
|
|
def teardown_method(self):
|
|
if self._original_drop_params is None:
|
|
if hasattr(litellm, "drop_params"):
|
|
delattr(litellm, "drop_params")
|
|
else:
|
|
litellm.drop_params = self._original_drop_params
|
|
|
|
def test_unsupported_params_raises_without_drop(self):
|
|
litellm.drop_params = False
|
|
optional_params: ImageEditOptionalRequestParams = {
|
|
"size": "1024x1024",
|
|
"unsupported_param": "value",
|
|
}
|
|
|
|
with pytest.raises(litellm.UnsupportedParamsError) as exc_info:
|
|
ImageEditRequestUtils.get_optional_params_image_edit(
|
|
model=self.model,
|
|
image_edit_provider_config=self.config,
|
|
image_edit_optional_params=optional_params,
|
|
)
|
|
|
|
assert "unsupported_param" in str(exc_info.value)
|
|
|
|
def test_drop_params_global_setting(self):
|
|
litellm.drop_params = True
|
|
optional_params: ImageEditOptionalRequestParams = {
|
|
"size": "1024x1024",
|
|
"unsupported_param": "value",
|
|
}
|
|
|
|
result = ImageEditRequestUtils.get_optional_params_image_edit(
|
|
model=self.model,
|
|
image_edit_provider_config=self.config,
|
|
image_edit_optional_params=optional_params,
|
|
)
|
|
|
|
assert "size" in result
|
|
assert "unsupported_param" not in result
|
|
|
|
def test_drop_params_explicit_parameter(self):
|
|
litellm.drop_params = False
|
|
optional_params: ImageEditOptionalRequestParams = {
|
|
"size": "1024x1024",
|
|
"unsupported_param": "value",
|
|
}
|
|
|
|
result = ImageEditRequestUtils.get_optional_params_image_edit(
|
|
model=self.model,
|
|
image_edit_provider_config=self.config,
|
|
image_edit_optional_params=optional_params,
|
|
drop_params=True,
|
|
)
|
|
|
|
assert "size" in result
|
|
assert "unsupported_param" not in result
|
|
|
|
def test_additional_drop_params(self):
|
|
litellm.drop_params = False
|
|
optional_params: ImageEditOptionalRequestParams = {
|
|
"size": "1024x1024",
|
|
"quality": "high",
|
|
}
|
|
|
|
result = ImageEditRequestUtils.get_optional_params_image_edit(
|
|
model=self.model,
|
|
image_edit_provider_config=self.config,
|
|
image_edit_optional_params=optional_params,
|
|
additional_drop_params=["quality"],
|
|
)
|
|
|
|
assert "size" in result
|
|
assert "quality" not in result
|
|
|
|
def test_drop_params_false_with_global_true(self):
|
|
litellm.drop_params = True
|
|
optional_params: ImageEditOptionalRequestParams = {
|
|
"size": "1024x1024",
|
|
"unsupported_param": "value",
|
|
}
|
|
|
|
result = ImageEditRequestUtils.get_optional_params_image_edit(
|
|
model=self.model,
|
|
image_edit_provider_config=self.config,
|
|
image_edit_optional_params=optional_params,
|
|
drop_params=False,
|
|
)
|
|
|
|
assert "size" in result
|
|
assert "unsupported_param" not in result
|
|
|
|
def test_supported_params_pass_through(self):
|
|
litellm.drop_params = False
|
|
optional_params: ImageEditOptionalRequestParams = {
|
|
"size": "1024x1024",
|
|
"quality": "high",
|
|
}
|
|
|
|
result = ImageEditRequestUtils.get_optional_params_image_edit(
|
|
model=self.model,
|
|
image_edit_provider_config=self.config,
|
|
image_edit_optional_params=optional_params,
|
|
)
|
|
|
|
assert result["size"] == "1024x1024"
|
|
assert result["quality"] == "high"
|
|
|
|
def test_additional_drop_params_with_unsupported_and_drop_true(self):
|
|
litellm.drop_params = True
|
|
optional_params: ImageEditOptionalRequestParams = {
|
|
"size": "1024x1024",
|
|
"quality": "high",
|
|
"unsupported_param": "value",
|
|
}
|
|
|
|
result = ImageEditRequestUtils.get_optional_params_image_edit(
|
|
model=self.model,
|
|
image_edit_provider_config=self.config,
|
|
image_edit_optional_params=optional_params,
|
|
additional_drop_params=["quality"],
|
|
)
|
|
|
|
assert "size" in result
|
|
assert "quality" not in result
|
|
assert "unsupported_param" not in result
|
|
|
|
|
|
class TestImageEditCustomPricing:
|
|
"""
|
|
Regression tests for https://github.com/BerriAI/litellm/issues/22244
|
|
|
|
image_edit must forward model_info and metadata into litellm_params
|
|
when calling update_environment_variables, so that custom pricing
|
|
detection works after PR #20679 stripped custom pricing fields from
|
|
the shared backend model key.
|
|
"""
|
|
|
|
def test_image_edit_passes_model_info_to_logging(self):
|
|
"""
|
|
When the router provides model_info with custom pricing fields,
|
|
image_edit should include model_info and metadata in litellm_params.
|
|
"""
|
|
from litellm.images.main import image_edit
|
|
|
|
custom_model_info = {
|
|
"id": "test-deployment-id",
|
|
"input_cost_per_image": 0.00676128,
|
|
"mode": "image_generation",
|
|
}
|
|
custom_metadata = {
|
|
"model_info": custom_model_info,
|
|
}
|
|
|
|
captured_litellm_params = {}
|
|
|
|
mock_logging_obj = MagicMock()
|
|
mock_logging_obj.model_call_details = {}
|
|
|
|
original_update = mock_logging_obj.update_from_kwargs
|
|
|
|
def capturing_update(**update_kwargs):
|
|
captured_litellm_params.update(update_kwargs.get("litellm_params", {}))
|
|
inner_kwargs = update_kwargs.get("kwargs", {})
|
|
if "metadata" in inner_kwargs:
|
|
captured_litellm_params["metadata"] = inner_kwargs["metadata"]
|
|
return original_update(**update_kwargs)
|
|
|
|
mock_logging_obj.update_from_kwargs = capturing_update
|
|
|
|
with (
|
|
patch(
|
|
"litellm.images.main.get_llm_provider",
|
|
return_value=("test-model", "openai", None, None),
|
|
),
|
|
patch(
|
|
"litellm.images.main.ProviderConfigManager.get_provider_image_edit_config",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"litellm.images.main._get_ImageEditRequestUtils",
|
|
return_value=MagicMock(
|
|
get_requested_image_edit_optional_param=MagicMock(return_value={}),
|
|
get_optional_params_image_edit=MagicMock(return_value={}),
|
|
),
|
|
),
|
|
patch("litellm.images.main.base_llm_http_handler") as mock_handler,
|
|
):
|
|
mock_handler.image_edit_handler.return_value = MagicMock()
|
|
|
|
try:
|
|
image_edit(
|
|
image=b"fake-image-data",
|
|
prompt="test prompt",
|
|
model="openai/test-model",
|
|
litellm_logging_obj=mock_logging_obj,
|
|
model_info=custom_model_info,
|
|
metadata=custom_metadata,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
assert "model_info" in captured_litellm_params
|
|
assert captured_litellm_params["model_info"] == custom_model_info
|
|
assert "metadata" in captured_litellm_params
|
|
assert captured_litellm_params["metadata"] == custom_metadata
|
|
|
|
def test_custom_pricing_detected_from_model_info_in_metadata(self):
|
|
litellm_params = {
|
|
"metadata": {
|
|
"model_info": {
|
|
"id": "deployment-id",
|
|
"input_cost_per_image": 0.00676128,
|
|
},
|
|
},
|
|
}
|
|
assert use_custom_pricing_for_model(litellm_params) is True
|
|
|
|
def test_custom_pricing_not_detected_without_model_info(self):
|
|
litellm_params = {"litellm_call_id": "test-call-id"}
|
|
assert use_custom_pricing_for_model(litellm_params) is False
|
|
|
|
|
|
class TestImageEditHandlerCredentialsForwarding:
|
|
"""
|
|
Regression tests for Vertex AI image_edit credentials bug.
|
|
|
|
image_edit handler must forward litellm_params to validate_environment,
|
|
so that credentials passed via YAML config (vertex_ai_project,
|
|
vertex_ai_credentials, etc.) reach the auth layer instead of falling
|
|
through to Application Default Credentials.
|
|
"""
|
|
|
|
def test_vertex_gemini_image_edit_reads_credentials_from_litellm_params(self):
|
|
"""
|
|
VertexAIGeminiImageEditConfig.validate_environment should read
|
|
vertex_ai_project/vertex_ai_credentials from litellm_params first.
|
|
"""
|
|
from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import (
|
|
VertexAIGeminiImageEditConfig,
|
|
)
|
|
|
|
config = VertexAIGeminiImageEditConfig()
|
|
|
|
litellm_params = {
|
|
"vertex_ai_project": "test-project-from-params",
|
|
"vertex_ai_credentials": "/path/to/creds.json",
|
|
}
|
|
|
|
with patch.object(
|
|
config, "_ensure_access_token", return_value=("token", "project")
|
|
) as mock_ensure:
|
|
config.validate_environment(
|
|
headers={},
|
|
model="test-model",
|
|
litellm_params=litellm_params,
|
|
)
|
|
|
|
mock_ensure.assert_called_once()
|
|
call_kwargs = mock_ensure.call_args[1]
|
|
|
|
assert call_kwargs["credentials"] == "/path/to/creds.json"
|
|
assert call_kwargs["project_id"] == "test-project-from-params"
|
|
|
|
def test_vertex_imagen_image_edit_reads_credentials_from_litellm_params(self):
|
|
"""
|
|
VertexAIImagenImageEditConfig.validate_environment should read
|
|
vertex_ai_project/vertex_ai_credentials from litellm_params first.
|
|
"""
|
|
from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import (
|
|
VertexAIImagenImageEditConfig,
|
|
)
|
|
|
|
config = VertexAIImagenImageEditConfig()
|
|
|
|
litellm_params = {
|
|
"vertex_ai_project": "test-project-from-params",
|
|
"vertex_ai_credentials": "/path/to/creds.json",
|
|
}
|
|
|
|
with patch.object(
|
|
config, "_ensure_access_token", return_value=("token", "project")
|
|
) as mock_ensure:
|
|
config.validate_environment(
|
|
headers={},
|
|
model="test-model",
|
|
litellm_params=litellm_params,
|
|
)
|
|
|
|
mock_ensure.assert_called_once()
|
|
call_kwargs = mock_ensure.call_args[1]
|
|
|
|
assert call_kwargs["credentials"] == "/path/to/creds.json"
|
|
assert call_kwargs["project_id"] == "test-project-from-params"
|
|
|
|
def test_vertex_imagen_get_complete_url_reads_project_and_location_from_litellm_params(
|
|
self,
|
|
):
|
|
"""
|
|
VertexAIImagenImageEditConfig.get_complete_url should read
|
|
vertex_ai_project and vertex_ai_location from litellm_params,
|
|
not only from env vars / global settings.
|
|
"""
|
|
from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import (
|
|
VertexAIImagenImageEditConfig,
|
|
)
|
|
|
|
config = VertexAIImagenImageEditConfig()
|
|
|
|
litellm_params = {
|
|
"vertex_ai_project": "param-project",
|
|
"vertex_ai_location": "us-east1",
|
|
}
|
|
|
|
url = config.get_complete_url(
|
|
model="vertex_ai/imagegeneration@002",
|
|
api_base=None,
|
|
litellm_params=litellm_params,
|
|
)
|
|
|
|
assert "param-project" in url
|
|
assert "us-east1" in url
|
|
|
|
def test_validate_environment_signature_includes_litellm_params(self):
|
|
"""
|
|
All image_edit config validate_environment methods should accept
|
|
litellm_params to allow credentials to be forwarded from the handler.
|
|
"""
|
|
import inspect
|
|
|
|
from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import (
|
|
VertexAIGeminiImageEditConfig,
|
|
)
|
|
from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import (
|
|
VertexAIImagenImageEditConfig,
|
|
)
|
|
from litellm.llms.openai.image_edit.transformation import (
|
|
OpenAIImageEditConfig,
|
|
)
|
|
|
|
configs = [
|
|
VertexAIGeminiImageEditConfig(),
|
|
VertexAIImagenImageEditConfig(),
|
|
OpenAIImageEditConfig(),
|
|
MockImageEditConfig(),
|
|
]
|
|
|
|
for config in configs:
|
|
sig = inspect.signature(config.validate_environment)
|
|
params = list(sig.parameters.keys())
|
|
|
|
assert "litellm_params" in params, (
|
|
f"{config.__class__.__name__}.validate_environment "
|
|
"missing litellm_params parameter"
|
|
)
|
|
assert "api_base" in params, (
|
|
f"{config.__class__.__name__}.validate_environment "
|
|
"missing api_base parameter"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_azure_image_edit_litellm_sdk():
|
|
"""Test Azure image edit with mocked httpx request to validate request body and URL"""
|
|
from litellm import aimage_edit
|
|
|
|
# Mock response for Azure image edit
|
|
mock_response = {
|
|
"created": 1589478378,
|
|
"data": [
|
|
{
|
|
"b64_json": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
|
}
|
|
],
|
|
}
|
|
|
|
class MockResponse:
|
|
def __init__(self, json_data, status_code):
|
|
self._json_data = json_data
|
|
self.status_code = status_code
|
|
self.text = json.dumps(json_data)
|
|
self.headers = {}
|
|
|
|
def json(self):
|
|
return self._json_data
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
# Configure the mock to return our response
|
|
mock_post.return_value = MockResponse(mock_response, 200)
|
|
|
|
litellm.turn_on_debug()
|
|
|
|
prompt = """
|
|
Create a studio ghibli style image that combines all the reference images. Make sure the person looks like a CTO.
|
|
"""
|
|
|
|
# Set up test environment variables
|
|
test_api_base = "https://ai-api-gw-uae-north.openai.azure.com"
|
|
test_api_key = "test-api-key"
|
|
test_api_version = "2025-04-01-preview"
|
|
|
|
result = await aimage_edit(
|
|
prompt=prompt,
|
|
model="azure/gpt-image-1",
|
|
api_base=test_api_base,
|
|
api_key=test_api_key,
|
|
api_version=test_api_version,
|
|
image=_make_test_images(),
|
|
)
|
|
|
|
# Verify the request was made correctly
|
|
mock_post.assert_called_once()
|
|
|
|
# Check the URL
|
|
call_args = mock_post.call_args
|
|
expected_url = f"{test_api_base}/openai/deployments/gpt-image-1/images/edits?api-version={test_api_version}"
|
|
actual_url = (
|
|
call_args.args[0] if call_args.args else call_args.kwargs.get("url")
|
|
)
|
|
print(f"Expected URL: {expected_url}")
|
|
print(f"Actual URL: {actual_url}")
|
|
assert (
|
|
actual_url == expected_url
|
|
), f"URL mismatch. Expected: {expected_url}, Got: {actual_url}"
|
|
|
|
# Check the request body
|
|
if "data" in call_args.kwargs:
|
|
# For multipart form data, check the data parameter
|
|
form_data = call_args.kwargs["data"]
|
|
print(
|
|
"Form data keys:",
|
|
list(form_data.keys()) if hasattr(form_data, "keys") else "Not a dict",
|
|
)
|
|
|
|
# Deployment is in the URL path; Azure rejects model in multipart for this route.
|
|
assert (
|
|
"model" not in form_data
|
|
), "model must not be in form data for Azure /openai/deployments/.../images/edits"
|
|
assert "prompt" in form_data, "prompt should be in the form data"
|
|
assert (
|
|
prompt.strip() in form_data["prompt"]
|
|
), f"Expected prompt to contain '{prompt.strip()}'"
|
|
|
|
# Check headers
|
|
headers = call_args.kwargs.get("headers", {})
|
|
print("Request headers:", headers)
|
|
assert (
|
|
"api-key" in headers
|
|
), "Azure image edit must use the api-key header, not Authorization: Bearer"
|
|
assert headers["api-key"] == test_api_key
|
|
assert (
|
|
"Authorization" not in headers
|
|
), "Azure image edit must not send an Authorization header when an api_key is provided"
|
|
|
|
print("result from image edit", result)
|
|
|
|
# Validate the response meets expected schema
|
|
ImageResponse.model_validate(result)
|
|
|
|
if isinstance(result, ImageResponse) and result.data:
|
|
image_base64 = result.data[0].b64_json
|
|
if image_base64:
|
|
image_bytes = base64.b64decode(image_base64)
|
|
|
|
# Save the image to a file
|
|
with open("test_image_edit.png", "wb") as f:
|
|
f.write(image_bytes)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_image_edit_cost_tracking():
|
|
"""Test OpenAI image edit cost tracking with custom logger"""
|
|
from litellm import aimage_edit, image_edit
|
|
|
|
test_custom_logger = TestCustomLogger()
|
|
litellm.logging_callback_manager._reset_all_callbacks()
|
|
litellm.callbacks = [test_custom_logger]
|
|
|
|
# Mock response for Azure image edit with usage data for cost tracking
|
|
mock_response = {
|
|
"created": 1589478378,
|
|
"data": [
|
|
{
|
|
"b64_json": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
|
}
|
|
],
|
|
"usage": {
|
|
"total_tokens": 1100,
|
|
"input_tokens": 100,
|
|
"input_tokens_details": {"image_tokens": 50, "text_tokens": 50},
|
|
"output_tokens": 1000,
|
|
},
|
|
}
|
|
|
|
class MockResponse:
|
|
def __init__(self, json_data, status_code):
|
|
self._json_data = json_data
|
|
self.status_code = status_code
|
|
self.text = json.dumps(json_data)
|
|
self.headers = {}
|
|
|
|
def json(self):
|
|
return self._json_data
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
# Configure the mock to return our response
|
|
mock_post.return_value = MockResponse(mock_response, 200)
|
|
|
|
litellm.turn_on_debug()
|
|
|
|
prompt = """
|
|
Create a studio ghibli style image that combines all the reference images. Make sure the person looks like a CTO.
|
|
"""
|
|
|
|
# Set up test environment variables
|
|
|
|
result = await aimage_edit(
|
|
prompt=prompt,
|
|
model="openai/gpt-image-1",
|
|
image=_make_test_images(),
|
|
)
|
|
|
|
# Verify the request was made correctly
|
|
mock_post.assert_called_once()
|
|
|
|
# Validate the response meets expected schema
|
|
ImageResponse.model_validate(result)
|
|
|
|
if isinstance(result, ImageResponse) and result.data:
|
|
image_base64 = result.data[0].b64_json
|
|
if image_base64:
|
|
image_bytes = base64.b64decode(image_base64)
|
|
|
|
# Save the image to a file
|
|
with open("test_image_edit.png", "wb") as f:
|
|
f.write(image_bytes)
|
|
|
|
await asyncio.sleep(5)
|
|
print(
|
|
"standard logging payload",
|
|
json.dumps(
|
|
test_custom_logger.standard_logging_payload, indent=4, default=str
|
|
),
|
|
)
|
|
|
|
# check model
|
|
assert test_custom_logger.standard_logging_payload["model"] == "gpt-image-1"
|
|
assert (
|
|
test_custom_logger.standard_logging_payload["custom_llm_provider"]
|
|
== "openai"
|
|
)
|
|
|
|
# check response_cost
|
|
assert test_custom_logger.standard_logging_payload["response_cost"] is not None
|
|
assert test_custom_logger.standard_logging_payload["response_cost"] > 0
|
|
|
|
|
|
def test_recraft_image_edit_config():
|
|
"""
|
|
Test Recraft image edit configuration parameter mapping and request transformation.
|
|
"""
|
|
from litellm.llms.recraft.image_edit.transformation import RecraftImageEditConfig
|
|
from litellm.types.images.main import ImageEditOptionalRequestParams
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
|
|
config = RecraftImageEditConfig()
|
|
|
|
supported_params = config.get_supported_openai_params("recraftv3")
|
|
expected_params = ["n", "response_format", "style"]
|
|
assert supported_params == expected_params
|
|
|
|
image_edit_params = ImageEditOptionalRequestParams(
|
|
{
|
|
"n": 2,
|
|
"response_format": "b64_json",
|
|
"style": "realistic_image",
|
|
"size": "1024x1024",
|
|
"quality": "high",
|
|
}
|
|
)
|
|
|
|
mapped_params = config.map_openai_params(
|
|
image_edit_params, "recraftv3", drop_params=True
|
|
)
|
|
|
|
assert mapped_params["n"] == 2
|
|
assert mapped_params["response_format"] == "b64_json"
|
|
assert mapped_params["style"] == "realistic_image"
|
|
assert "size" not in mapped_params
|
|
assert "quality" not in mapped_params
|
|
|
|
mock_image = b"fake_image_data"
|
|
prompt = "winter landscape"
|
|
litellm_params = GenericLiteLLMParams(api_key="test_key")
|
|
|
|
data, files = config.transform_image_edit_request(
|
|
model="recraftv3",
|
|
prompt=prompt,
|
|
image=mock_image,
|
|
image_edit_optional_request_params={"strength": 0.7, "n": 1},
|
|
litellm_params=litellm_params,
|
|
headers={},
|
|
)
|
|
|
|
assert data["prompt"] == prompt
|
|
assert data["strength"] == 0.7
|
|
assert data["model"] == "recraftv3"
|
|
|
|
assert len(files) == 1
|
|
assert files[0][0] == "image"
|
|
assert files[0][1][1] == mock_image
|
|
assert files[0][1][2] == "image/png"
|
|
|
|
|
|
@pytest.mark.flaky(retries=3, delay=2)
|
|
@pytest.mark.asyncio
|
|
async def test_image_edit_array_handling():
|
|
"""Test that the image parameter correctly handles both single items and arrays"""
|
|
from litellm import aimage_edit
|
|
|
|
mock_response = {
|
|
"created": 1589478378,
|
|
"data": [
|
|
{
|
|
"b64_json": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
|
}
|
|
],
|
|
}
|
|
|
|
class MockResponse:
|
|
def __init__(self, json_data, status_code):
|
|
self._json_data = json_data
|
|
self.status_code = status_code
|
|
self.text = json.dumps(json_data)
|
|
self.headers = {}
|
|
|
|
def json(self):
|
|
return self._json_data
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(mock_response, 200)
|
|
|
|
prompt = "Test prompt"
|
|
|
|
result1 = await aimage_edit(
|
|
prompt=prompt,
|
|
model="gpt-image-1",
|
|
image=_make_single_test_image(),
|
|
)
|
|
|
|
result2 = await aimage_edit(
|
|
prompt=prompt,
|
|
model="gpt-image-1",
|
|
image=_make_test_images(),
|
|
)
|
|
|
|
ImageResponse.model_validate(result1)
|
|
ImageResponse.model_validate(result2)
|
|
|
|
assert mock_post.call_count == 2
|
|
|
|
|
|
class TestCustomLogger(CustomLogger):
|
|
def __init__(self):
|
|
self.standard_logging_payload: Optional[StandardLoggingPayload] = None
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
self.standard_logging_payload = kwargs.get("standard_logging_object", None)
|
|
pass
|