diff --git a/litellm/llms/azure/azure.py b/litellm/llms/azure/azure.py index c0e070b6c1..9291269d15 100644 --- a/litellm/llms/azure/azure.py +++ b/litellm/llms/azure/azure.py @@ -44,6 +44,7 @@ from .common_utils import ( select_azure_base_url_or_endpoint, ) from .image_generation import get_azure_image_generation_config +from .image_generation.http_utils import azure_deployment_image_generation_json_body class AzureOpenAIAssistantsAPIConfig: @@ -966,9 +967,10 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): content=json.dumps(result).encode("utf-8"), request=httpx.Request(method="POST", url="https://api.openai.com/v1"), ) + request_json = azure_deployment_image_generation_json_body(api_base, data) return await async_handler.post( url=api_base, - json=data, + json=request_json, headers=headers, ) @@ -1085,9 +1087,10 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): content=json.dumps(result).encode("utf-8"), request=httpx.Request(method="POST", url="https://api.openai.com/v1"), ) + request_json = azure_deployment_image_generation_json_body(api_base, data) return sync_handler.post( url=api_base, - json=data, + json=request_json, headers=headers, ) diff --git a/litellm/llms/azure/image_edit/transformation.py b/litellm/llms/azure/image_edit/transformation.py index dffa1c9eea..0b6ecfb076 100644 --- a/litellm/llms/azure/image_edit/transformation.py +++ b/litellm/llms/azure/image_edit/transformation.py @@ -9,6 +9,19 @@ from litellm.utils import _add_path_to_api_base class AzureImageEditConfig(OpenAIImageEditConfig): + @staticmethod + def azure_deployment_image_edit_form_data(data: dict, request_url: str) -> dict: + """ + Azure OpenAI ``.../openai/deployments/{deployment}/images/edits`` routes by + deployment in the URL; including ``model`` in multipart fields can break + the same way as image generations (LiteLLM #26316). + + Non-deployment edit URLs keep ``model`` when present. + """ + if "images/edits" in request_url and "/openai/deployments/" in request_url: + return {k: v for k, v in data.items() if k != "model"} + return data + def validate_environment( self, headers: dict, @@ -83,3 +96,8 @@ class AzureImageEditConfig(OpenAIImageEditConfig): final_url = httpx.URL(new_url).copy_with(params=query_params) return str(final_url) + + def finalize_image_edit_request_data( + self, data: dict, resolved_request_url: str + ) -> dict: + return self.azure_deployment_image_edit_form_data(data, resolved_request_url) diff --git a/litellm/llms/azure/image_generation/__init__.py b/litellm/llms/azure/image_generation/__init__.py index a9cf151464..f60e446f0c 100644 --- a/litellm/llms/azure/image_generation/__init__.py +++ b/litellm/llms/azure/image_generation/__init__.py @@ -6,11 +6,13 @@ from litellm.llms.base_llm.image_generation.transformation import ( from .dall_e_2_transformation import AzureDallE2ImageGenerationConfig from .dall_e_3_transformation import AzureDallE3ImageGenerationConfig from .gpt_transformation import AzureGPTImageGenerationConfig +from .http_utils import azure_deployment_image_generation_json_body __all__ = [ "AzureDallE2ImageGenerationConfig", "AzureDallE3ImageGenerationConfig", "AzureGPTImageGenerationConfig", + "azure_deployment_image_generation_json_body", ] diff --git a/litellm/llms/azure/image_generation/http_utils.py b/litellm/llms/azure/image_generation/http_utils.py new file mode 100644 index 0000000000..03c425eeff --- /dev/null +++ b/litellm/llms/azure/image_generation/http_utils.py @@ -0,0 +1,17 @@ +"""HTTP helpers for Azure OpenAI image generation (REST, not SDK).""" + + +def azure_deployment_image_generation_json_body(api_base: str, data: dict) -> dict: + """ + Build the JSON body for Azure OpenAI image generation POSTs. + + For ``.../openai/deployments/{deployment}/images/generations``, routing uses the + deployment in the URL only; sending ``model`` in the body (especially the deployment + name) breaks some models (e.g. gpt-image-2). See LiteLLM #26316. + + Provider-style URLs (e.g. ``/providers/...`` for FLUX on Azure AI) keep all keys + so non–OpenAI-deployment payloads still work. + """ + if "images/generations" in api_base and "/openai/deployments/" in api_base: + return {k: v for k, v in data.items() if k != "model"} + return data diff --git a/litellm/llms/base_llm/image_edit/transformation.py b/litellm/llms/base_llm/image_edit/transformation.py index cea96bde74..92429573ff 100644 --- a/litellm/llms/base_llm/image_edit/transformation.py +++ b/litellm/llms/base_llm/image_edit/transformation.py @@ -102,6 +102,18 @@ class BaseImageEditConfig(ABC): ) -> Tuple[Dict, RequestFiles]: pass + def finalize_image_edit_request_data( + self, data: dict, resolved_request_url: str + ) -> dict: + """ + Last pass on the request dict after ``transform_image_edit_request``, using the + exact URL string used for the HTTP POST (same as ``get_complete_url`` output). + + The handler sends this dict as ``data=`` for multipart providers or ``json=`` + for JSON-only providers; default implementation returns ``data`` unchanged. + """ + return data + @abstractmethod def transform_image_edit_response( self, diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 0c4816fcda..2ffc7acbfb 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5579,6 +5579,9 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, headers=headers, ) + data = image_edit_provider_config.finalize_image_edit_request_data( + data, api_base + ) ## LOGGING logging_obj.pre_call( @@ -5677,6 +5680,9 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, headers=headers, ) + data = image_edit_provider_config.finalize_image_edit_request_data( + data, api_base + ) ## LOGGING logging_obj.pre_call( diff --git a/tests/image_gen_tests/request_payloads/azure_gpt_image_1.json b/tests/image_gen_tests/request_payloads/azure_gpt_image_1.json index 1ad57bc0d4..596e9be614 100644 --- a/tests/image_gen_tests/request_payloads/azure_gpt_image_1.json +++ b/tests/image_gen_tests/request_payloads/azure_gpt_image_1.json @@ -1,4 +1,3 @@ { - "model": "gpt-image-1", "prompt": "test prompt" } diff --git a/tests/image_gen_tests/test_image_edits.py b/tests/image_gen_tests/test_image_edits.py index 3504065a10..dcb04c597e 100644 --- a/tests/image_gen_tests/test_image_edits.py +++ b/tests/image_gen_tests/test_image_edits.py @@ -244,7 +244,7 @@ async def test_openai_image_edit_with_bytesio(): @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 image_edit, aimage_edit + from litellm import aimage_edit # Mock response for Azure image edit mock_response = { @@ -316,12 +316,11 @@ async def test_azure_image_edit_litellm_sdk(): list(form_data.keys()) if hasattr(form_data, "keys") else "Not a dict", ) - # Validate that model and prompt are in the form data - assert "model" in form_data, "model should be in form data" - assert "prompt" in form_data, "prompt should be in form data" + # Deployment is in the URL path; Azure rejects model in multipart for this route. assert ( - form_data["model"] == "gpt-image-1" - ), f"Expected model 'gpt-image-1', got {form_data['model']}" + "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()}'" diff --git a/tests/image_gen_tests/test_image_generation.py b/tests/image_gen_tests/test_image_generation.py index fb7275101a..5152e3e012 100644 --- a/tests/image_gen_tests/test_image_generation.py +++ b/tests/image_gen_tests/test_image_generation.py @@ -393,6 +393,7 @@ async def test_aiml_image_generation_with_dynamic_api_key(): @pytest.mark.asyncio async def test_azure_image_generation_request_body(): + """Azure deployment URL selects the model; JSON body omits ``model`` (#26316).""" from litellm import aimage_generation test_dir = os.path.dirname(__file__) diff --git a/tests/test_litellm/llms/azure/image_edit/test_azure_image_edit_transformation.py b/tests/test_litellm/llms/azure/image_edit/test_azure_image_edit_transformation.py new file mode 100644 index 0000000000..96cbd88da1 --- /dev/null +++ b/tests/test_litellm/llms/azure/image_edit/test_azure_image_edit_transformation.py @@ -0,0 +1,51 @@ +from litellm.llms.azure.image_edit.transformation import AzureImageEditConfig +from litellm.types.router import GenericLiteLLMParams + + +def test_azure_deployment_image_edit_form_data_strips_model(): + url = ( + "https://example.openai.azure.com/openai/deployments/my-dep/" + "images/edits?api-version=2025-02-01-preview" + ) + data = {"model": "my-dep", "prompt": "x", "n": 1} + out = AzureImageEditConfig.azure_deployment_image_edit_form_data(data, url) + assert "model" not in out + assert out == {"prompt": "x", "n": 1} + + +def test_azure_deployment_image_edit_form_data_keeps_model_non_deployment_url(): + url = "https://api.openai.com/v1/images/edits" + data = {"model": "gpt-image-1", "prompt": "x"} + out = AzureImageEditConfig.azure_deployment_image_edit_form_data(data, url) + assert out == data + + +def test_azure_finalize_image_edit_strips_model_after_openai_transform(): + """OpenAI transform still includes model; finalize uses the real request URL.""" + config = AzureImageEditConfig() + model = "gpt-image-2-dep" + prompt = "add a hat" + image = b"fake_png_bytes" + litellm_params = GenericLiteLLMParams( + api_base="https://example.openai.azure.com", + api_version="2025-02-01-preview", + ) + data, files = config.transform_image_edit_request( + model=model, + prompt=prompt, + image=image, + image_edit_optional_request_params={"n": 1}, + litellm_params=litellm_params, + headers={}, + ) + assert data.get("model") == model + resolved = config.get_complete_url( + model=model, + api_base=litellm_params.api_base, + litellm_params=litellm_params.model_dump(exclude_none=True), + ) + data_out = config.finalize_image_edit_request_data(data, resolved) + assert "model" not in data_out + assert data_out.get("prompt") == prompt + assert data_out.get("n") == 1 + assert len(files) >= 1 diff --git a/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py b/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py index 3c9421ff2d..f49b3f09d6 100644 --- a/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py +++ b/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py @@ -12,6 +12,10 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm from litellm.llms.azure.azure import AzureChatCompletion +from litellm.llms.azure.image_generation.http_utils import ( + azure_deployment_image_generation_json_body, +) +from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.llms.azure.image_generation import ( AzureDallE3ImageGenerationConfig, get_azure_image_generation_config, @@ -33,6 +37,26 @@ def test_azure_image_generation_config(received_model, expected_config): ) +def test_azure_deployment_image_generation_json_body(): + """Deployment-scoped Azure image URL must not send ``model`` in JSON.""" + api = ( + "https://example.openai.azure.com/openai/deployments/my-dep/" + "images/generations?api-version=2025-04-01-preview" + ) + data = {"model": "my-dep", "prompt": "x", "n": 1} + out = azure_deployment_image_generation_json_body(api, data) + assert "model" not in out + assert out == {"prompt": "x", "n": 1} + + +def test_azure_providers_image_generation_json_body_keeps_model(): + """Non-deployment routes (e.g. FLUX on Azure AI) keep the payload unchanged.""" + api = "https://example.services.ai.azure.com/providers/blackforestlabs/v1/flux-2-pro?api-version=preview" + data = {"model": "flux.2-pro", "prompt": "x"} + out = azure_deployment_image_generation_json_body(api, data) + assert out == data + + def test_azure_image_generation_flattens_extra_body(): """ Test that Azure image generation correctly flattens extra_body parameters. @@ -260,20 +284,17 @@ def test_azure_image_generation_drop_params_false_raises_error(): def test_azure_image_generation_base_model_vs_deployment_name(): """ - Test that Azure image generation correctly uses base_model in request body - but deployment name in the URL. + Test that Azure image generation omits ``model`` from the JSON body for + deployment URLs while keeping the deployment in the path. - When base_model is specified in litellm_params, the request should: - 1. Use base_model (e.g., "gpt-image-1.5") in the JSON request body - 2. Use the deployment name (e.g., "gpt-image-15") in the URL path - - This is important because Azure expects: - - URL: /openai/deployments/{deployment_name}/images/generations - - Body: {"model": "{base_model}", ...} + Azure OpenAI routes image generation by deployment in the URL; the REST body + must not include ``model`` (sending deployment or base model there can break + gpt-image-2; see LiteLLM #26316). ``base_model`` in litellm_params is still used + internally for logging / hidden params. Example config: - model: azure/gpt-image-15 # deployment name - base_model: gpt-image-1.5 # actual model name + model: azure/gpt-image-15 # deployment name (URL only) + base_model: gpt-image-1.5 # optional, for LiteLLM metadata """ from unittest.mock import MagicMock @@ -295,26 +316,21 @@ def test_azure_image_generation_base_model_vs_deployment_name(): optional_params = {"n": 1, "size": "1024x1024"} - # Mock the HTTP request to capture what gets sent + mock_http_response = MagicMock() + mock_http_response.status_code = 200 + mock_http_response.json.return_value = { + "created": 1234567890, + "data": [{"url": "https://example.com/image.png", "revised_prompt": prompt}], + } + with patch.object( - azure_chat_completion, - "make_sync_azure_httpx_request", - return_value=MagicMock( - json=lambda: { - "created": 1234567890, - "data": [ - {"url": "https://example.com/image.png", "revised_prompt": prompt} - ], - } - ), - ) as mock_request: - # Mock logging object + HTTPHandler, "post", return_value=mock_http_response + ) as mock_post: logging_obj = MagicMock() logging_obj.pre_call = MagicMock() logging_obj.post_call = MagicMock() - # Call the image_generation method - response = azure_chat_completion.image_generation( + azure_chat_completion.image_generation( prompt=prompt, timeout=60.0, optional_params=optional_params, @@ -327,43 +343,31 @@ def test_azure_image_generation_base_model_vs_deployment_name(): litellm_params=litellm_params, ) - # Verify the mock was called - assert mock_request.called, "HTTP request should have been made" - - # Get the call arguments - call_kwargs = mock_request.call_args.kwargs - - # Verify the URL uses the deployment name (not base_model) - api_base_used = call_kwargs.get("api_base", "") - assert model in api_base_used, ( - f"URL should contain deployment name '{model}', " - f"but got: {api_base_used}" - ) - assert base_model not in api_base_used or base_model == model, ( + assert mock_post.called, "HTTPHandler.post should be invoked" + post_kwargs = mock_post.call_args.kwargs + url_used = post_kwargs.get("url", "") + assert ( + model in url_used + ), f"URL should contain deployment name '{model}', but got: {url_used}" + assert base_model not in url_used or base_model == model, ( f"URL should NOT contain base_model '{base_model}' when it differs from deployment name, " - f"but got: {api_base_used}" + f"but got: {url_used}" ) - # Verify the request body uses base_model (not deployment name) - request_data = call_kwargs.get("data", {}) - assert request_data.get("model") == base_model, ( - f"Request body 'model' field should be base_model '{base_model}', " - f"but got: {request_data.get('model')}" - ) - - # Verify other fields are correct - assert request_data.get("prompt") == prompt - assert request_data.get("n") == 1 - assert request_data.get("size") == "1024x1024" + wire_json = post_kwargs.get("json") or {} + assert ( + "model" not in wire_json + ), f"Azure deployment image gen must not send 'model' in JSON body; got keys: {list(wire_json)}" + assert wire_json.get("prompt") == prompt + assert wire_json.get("n") == 1 + assert wire_json.get("size") == "1024x1024" @pytest.mark.asyncio async def test_azure_aimage_generation_base_model_vs_deployment_name(): """ - Test that Azure async image generation correctly uses base_model in request body - but deployment name in the URL. - - This is the async version of test_azure_image_generation_base_model_vs_deployment_name. + Async variant of test_azure_image_generation_base_model_vs_deployment_name: + deployment in URL, no ``model`` in the JSON body sent to Azure. """ from unittest.mock import MagicMock @@ -384,27 +388,24 @@ async def test_azure_aimage_generation_base_model_vs_deployment_name(): "api_version": api_version, } - # Mock the HTTP request to capture what gets sent - with patch.object( - azure_chat_completion, - "make_async_azure_httpx_request", - new_callable=AsyncMock, - return_value=MagicMock( - json=lambda: { - "created": 1234567890, - "data": [ - {"url": "https://example.com/image.png", "revised_prompt": prompt} - ], - } - ), - ) as mock_request: - # Mock logging object + mock_http_response = MagicMock() + mock_http_response.status_code = 200 + mock_http_response.json.return_value = { + "created": 1234567890, + "data": [{"url": "https://example.com/image.png", "revised_prompt": prompt}], + } + + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=mock_http_response) + + with patch( + "litellm.llms.azure.azure.get_async_httpx_client", return_value=mock_client + ): logging_obj = MagicMock() logging_obj.pre_call = MagicMock() logging_obj.post_call = MagicMock() - # Call the aimage_generation method - response = await azure_chat_completion.aimage_generation( + await azure_chat_completion.aimage_generation( data=data, model_response=None, azure_client_params=azure_client_params, @@ -412,30 +413,14 @@ async def test_azure_aimage_generation_base_model_vs_deployment_name(): input=[], logging_obj=logging_obj, headers={}, - model=model, # Pass the deployment name + model=model, timeout=60.0, ) - # Verify the mock was called - assert mock_request.called, "HTTP request should have been made" - - # Get the call arguments - call_kwargs = mock_request.call_args.kwargs - - # Verify the URL uses the deployment name (not base_model) - api_base_used = call_kwargs.get("api_base", "") - assert model in api_base_used, ( - f"URL should contain deployment name '{model}', " - f"but got: {api_base_used}" - ) - assert base_model not in api_base_used or base_model == model, ( - f"URL should NOT contain base_model '{base_model}' when it differs from deployment name, " - f"but got: {api_base_used}" - ) - - # Verify the request body uses base_model (not deployment name) - request_data = call_kwargs.get("data", {}) - assert request_data.get("model") == base_model, ( - f"Request body 'model' field should be base_model '{base_model}', " - f"but got: {request_data.get('model')}" - ) + assert mock_client.post.called + post_kwargs = mock_client.post.call_args.kwargs + url_used = post_kwargs.get("url", "") + assert model in url_used + wire_json = post_kwargs.get("json") or {} + assert "model" not in wire_json + assert data.get("model") == base_model