From 766b67cf0df4e99209946a60314332b4d6860675 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 4 May 2026 11:52:52 +0530 Subject: [PATCH 1/5] fix(azure): omit model from image generation and image edit deployment requests Azure OpenAI routes image gen/edit by deployment in the URL; sending the deployment id in model breaks gpt-image-2 (invalid_value). Strip model from JSON for deployments/.../images/generations and from multipart data for .../images/edits. Non-deployment URLs (e.g. Azure AI FLUX) unchanged. Fixes #26316. Co-authored-by: Cursor --- litellm/llms/azure/azure.py | 26 ++++++- .../llms/azure/image_edit/transformation.py | 48 ++++++++++++- .../test_azure_image_edit_transformation.py | 43 +++++++++++ .../test_azure_image_generation_init.py | 72 ++++++++++++------- 4 files changed, 159 insertions(+), 30 deletions(-) create mode 100644 tests/test_litellm/llms/azure/image_edit/test_azure_image_edit_transformation.py diff --git a/litellm/llms/azure/azure.py b/litellm/llms/azure/azure.py index c0e070b6c1..b84f24622f 100644 --- a/litellm/llms/azure/azure.py +++ b/litellm/llms/azure/azure.py @@ -133,6 +133,22 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): def __init__(self) -> None: super().__init__() + @staticmethod + def azure_deployment_image_generation_json_body(api_base: str, data: dict) -> dict: + """ + JSON body for Azure OpenAI image generation HTTP calls. + + 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 + def make_sync_azure_openai_chat_completion_request( self, azure_client: Union[AzureOpenAI, OpenAI], @@ -966,9 +982,12 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): content=json.dumps(result).encode("utf-8"), request=httpx.Request(method="POST", url="https://api.openai.com/v1"), ) + request_json = AzureChatCompletion.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 +1104,12 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): content=json.dumps(result).encode("utf-8"), request=httpx.Request(method="POST", url="https://api.openai.com/v1"), ) + request_json = AzureChatCompletion.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..376bfa994d 100644 --- a/litellm/llms/azure/image_edit/transformation.py +++ b/litellm/llms/azure/image_edit/transformation.py @@ -1,14 +1,30 @@ -from typing import Optional, cast +from typing import Dict, Optional, Tuple, cast import httpx +from httpx._types import RequestFiles import litellm from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import FileTypes +from litellm.types.router import GenericLiteLLMParams 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 +99,33 @@ class AzureImageEditConfig(OpenAIImageEditConfig): final_url = httpx.URL(new_url).copy_with(params=query_params) return str(final_url) + + def transform_image_edit_request( + self, + model: str, + prompt: Optional[str], + image: Optional[FileTypes], + image_edit_optional_request_params: Dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Tuple[Dict, RequestFiles]: + data, files = super().transform_image_edit_request( + model=model, + prompt=prompt, + image=image, + image_edit_optional_request_params=image_edit_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + litellm_params_dict = ( + litellm_params.model_dump(exclude_none=True) + if hasattr(litellm_params, "model_dump") + else dict(litellm_params) + ) + resolved_url = self.get_complete_url( + model=model, + api_base=litellm_params_dict.get("api_base"), + litellm_params=litellm_params_dict, + ) + data = self.azure_deployment_image_edit_form_data(data, resolved_url) + return data, files 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..bfdd6b95f2 --- /dev/null +++ b/tests/test_litellm/llms/azure/image_edit/test_azure_image_edit_transformation.py @@ -0,0 +1,43 @@ +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_transform_image_edit_request_omits_model_for_deployment(): + 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 "model" not in data + assert data.get("prompt") == prompt + assert data.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..aec0dadd7d 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 @@ -33,6 +33,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 = AzureChatCompletion.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 = AzureChatCompletion.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 +280,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 @@ -344,26 +361,27 @@ def test_azure_image_generation_base_model_vs_deployment_name(): f"but got: {api_base_used}" ) - # Verify the request body uses base_model (not deployment name) + # Verify the HTTP JSON body omits model (deployment is only in the URL) 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')}" + wire_json = AzureChatCompletion.azure_deployment_image_generation_json_body( + api_base_used, request_data ) + assert ( + "model" not in wire_json + ), f"Azure deployment image gen must not send 'model' in JSON body; got keys: {list(wire_json)}" + assert request_data.get("model") == base_model # internal dict unchanged - # Verify other fields are correct - assert request_data.get("prompt") == prompt - assert request_data.get("n") == 1 - assert request_data.get("size") == "1024x1024" + # Verify other fields are correct on the wire payload + 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 @@ -433,9 +451,9 @@ async def test_azure_aimage_generation_base_model_vs_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')}" + wire_json = AzureChatCompletion.azure_deployment_image_generation_json_body( + api_base_used, request_data ) + assert "model" not in wire_json + assert request_data.get("model") == base_model From 87cf5107e17177dfbc8d6c275cfe2a19c01c4e95 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 4 May 2026 12:09:05 +0530 Subject: [PATCH 2/5] test(azure): exercise image gen JSON filter via HTTP client; dedupe image edit URL - Image generation tests patch HTTPHandler.post / get_async_httpx_client so make_*_azure_httpx_request runs and wire json is asserted on call kwargs. - Azure image edit: strip model in finalize_image_edit_multipart_data using the same URL string the handler passes to POST (no second get_complete_url in transform). BaseImageEditConfig default finalize is a no-op. Co-authored-by: Cursor --- .../llms/azure/image_edit/transformation.py | 38 +----- .../base_llm/image_edit/transformation.py | 9 ++ litellm/llms/custom_httpx/llm_http_handler.py | 6 + .../test_azure_image_edit_transformation.py | 16 ++- .../test_azure_image_generation_init.py | 118 ++++++------------ 5 files changed, 73 insertions(+), 114 deletions(-) diff --git a/litellm/llms/azure/image_edit/transformation.py b/litellm/llms/azure/image_edit/transformation.py index 376bfa994d..ac2fe2cf60 100644 --- a/litellm/llms/azure/image_edit/transformation.py +++ b/litellm/llms/azure/image_edit/transformation.py @@ -1,13 +1,10 @@ -from typing import Dict, Optional, Tuple, cast +from typing import Dict, Optional, cast import httpx -from httpx._types import RequestFiles import litellm from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig from litellm.secret_managers.main import get_secret_str -from litellm.types.llms.openai import FileTypes -from litellm.types.router import GenericLiteLLMParams from litellm.utils import _add_path_to_api_base @@ -100,32 +97,7 @@ class AzureImageEditConfig(OpenAIImageEditConfig): return str(final_url) - def transform_image_edit_request( - self, - model: str, - prompt: Optional[str], - image: Optional[FileTypes], - image_edit_optional_request_params: Dict, - litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> Tuple[Dict, RequestFiles]: - data, files = super().transform_image_edit_request( - model=model, - prompt=prompt, - image=image, - image_edit_optional_request_params=image_edit_optional_request_params, - litellm_params=litellm_params, - headers=headers, - ) - litellm_params_dict = ( - litellm_params.model_dump(exclude_none=True) - if hasattr(litellm_params, "model_dump") - else dict(litellm_params) - ) - resolved_url = self.get_complete_url( - model=model, - api_base=litellm_params_dict.get("api_base"), - litellm_params=litellm_params_dict, - ) - data = self.azure_deployment_image_edit_form_data(data, resolved_url) - return data, files + def finalize_image_edit_multipart_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/base_llm/image_edit/transformation.py b/litellm/llms/base_llm/image_edit/transformation.py index cea96bde74..71710f5b66 100644 --- a/litellm/llms/base_llm/image_edit/transformation.py +++ b/litellm/llms/base_llm/image_edit/transformation.py @@ -102,6 +102,15 @@ class BaseImageEditConfig(ABC): ) -> Tuple[Dict, RequestFiles]: pass + def finalize_image_edit_multipart_data( + self, data: dict, resolved_request_url: str + ) -> dict: + """ + Adjust non-file form fields after ``transform_image_edit_request`` using the + exact URL that will be used for the HTTP POST (same string as ``get_complete_url``). + """ + 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..de476c2fb0 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_multipart_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_multipart_data( + data, api_base + ) ## LOGGING logging_obj.pre_call( 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 index bfdd6b95f2..d80e0aa90a 100644 --- 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 @@ -20,7 +20,8 @@ def test_azure_deployment_image_edit_form_data_keeps_model_non_deployment_url(): assert out == data -def test_azure_transform_image_edit_request_omits_model_for_deployment(): +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" @@ -37,7 +38,14 @@ def test_azure_transform_image_edit_request_omits_model_for_deployment(): litellm_params=litellm_params, headers={}, ) - assert "model" not in data - assert data.get("prompt") == prompt - assert data.get("n") == 1 + 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_multipart_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 aec0dadd7d..6e91fdf865 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,7 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm from litellm.llms.azure.azure import AzureChatCompletion +from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.llms.azure.image_generation import ( AzureDallE3ImageGenerationConfig, get_azure_image_generation_config, @@ -312,26 +313,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, @@ -344,34 +340,21 @@ 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 HTTP JSON body omits model (deployment is only in the URL) - request_data = call_kwargs.get("data", {}) - wire_json = AzureChatCompletion.azure_deployment_image_generation_json_body( - api_base_used, request_data - ) + 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 request_data.get("model") == base_model # internal dict unchanged - - # Verify other fields are correct on the wire payload assert wire_json.get("prompt") == prompt assert wire_json.get("n") == 1 assert wire_json.get("size") == "1024x1024" @@ -402,27 +385,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, @@ -430,30 +410,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}" - ) - - request_data = call_kwargs.get("data", {}) - wire_json = AzureChatCompletion.azure_deployment_image_generation_json_body( - api_base_used, request_data - ) + 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 request_data.get("model") == base_model + assert data.get("model") == base_model From 41ac026fc134b7de3296853ea8de1e4baf7b242f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 4 May 2026 12:20:29 +0530 Subject: [PATCH 3/5] test(image_gen): expect no model in Azure image edit multipart (#26316) Align test_azure_image_edit_litellm_sdk with deployment-scoped Azure edits. Co-authored-by: Cursor --- litellm/llms/azure/image_edit/transformation.py | 2 +- tests/image_gen_tests/test_image_edits.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/litellm/llms/azure/image_edit/transformation.py b/litellm/llms/azure/image_edit/transformation.py index ac2fe2cf60..321c2dee4e 100644 --- a/litellm/llms/azure/image_edit/transformation.py +++ b/litellm/llms/azure/image_edit/transformation.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, cast +from typing import Optional, cast import httpx 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()}'" From c53c71ad6641d3b3a70a6d8659157359d62c6b26 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 4 May 2026 18:09:30 +0530 Subject: [PATCH 4/5] test(image_gen): align Azure image gen fixture with body omitting model Expected JSON matches deployment-scoped Azure POST (#26316). Co-authored-by: Cursor --- tests/image_gen_tests/request_payloads/azure_gpt_image_1.json | 1 - tests/image_gen_tests/test_image_generation.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) 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_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__) From bb0e4168ad3a2e3e520802c1cfddc7dc9e18e338 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 5 May 2026 08:49:46 +0530 Subject: [PATCH 5/5] refactor(azure): move image gen JSON helper; rename image edit finalize hook - Add image_generation/http_utils.azure_deployment_image_generation_json_body; call from azure.py (keeps AzureChatCompletion focused on chat). - Rename finalize_image_edit_multipart_data to finalize_image_edit_request_data with docstring covering multipart and JSON POST payloads (review feedback). Co-authored-by: Cursor --- litellm/llms/azure/azure.py | 25 +++---------------- .../llms/azure/image_edit/transformation.py | 2 +- .../llms/azure/image_generation/__init__.py | 2 ++ .../llms/azure/image_generation/http_utils.py | 17 +++++++++++++ .../base_llm/image_edit/transformation.py | 9 ++++--- litellm/llms/custom_httpx/llm_http_handler.py | 4 +-- .../test_azure_image_edit_transformation.py | 2 +- .../test_azure_image_generation_init.py | 7 ++++-- 8 files changed, 37 insertions(+), 31 deletions(-) create mode 100644 litellm/llms/azure/image_generation/http_utils.py diff --git a/litellm/llms/azure/azure.py b/litellm/llms/azure/azure.py index b84f24622f..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: @@ -133,22 +134,6 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): def __init__(self) -> None: super().__init__() - @staticmethod - def azure_deployment_image_generation_json_body(api_base: str, data: dict) -> dict: - """ - JSON body for Azure OpenAI image generation HTTP calls. - - 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 - def make_sync_azure_openai_chat_completion_request( self, azure_client: Union[AzureOpenAI, OpenAI], @@ -982,9 +967,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): content=json.dumps(result).encode("utf-8"), request=httpx.Request(method="POST", url="https://api.openai.com/v1"), ) - request_json = AzureChatCompletion.azure_deployment_image_generation_json_body( - api_base, data - ) + request_json = azure_deployment_image_generation_json_body(api_base, data) return await async_handler.post( url=api_base, json=request_json, @@ -1104,9 +1087,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): content=json.dumps(result).encode("utf-8"), request=httpx.Request(method="POST", url="https://api.openai.com/v1"), ) - request_json = AzureChatCompletion.azure_deployment_image_generation_json_body( - api_base, data - ) + request_json = azure_deployment_image_generation_json_body(api_base, data) return sync_handler.post( url=api_base, json=request_json, diff --git a/litellm/llms/azure/image_edit/transformation.py b/litellm/llms/azure/image_edit/transformation.py index 321c2dee4e..0b6ecfb076 100644 --- a/litellm/llms/azure/image_edit/transformation.py +++ b/litellm/llms/azure/image_edit/transformation.py @@ -97,7 +97,7 @@ class AzureImageEditConfig(OpenAIImageEditConfig): return str(final_url) - def finalize_image_edit_multipart_data( + 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 71710f5b66..92429573ff 100644 --- a/litellm/llms/base_llm/image_edit/transformation.py +++ b/litellm/llms/base_llm/image_edit/transformation.py @@ -102,12 +102,15 @@ class BaseImageEditConfig(ABC): ) -> Tuple[Dict, RequestFiles]: pass - def finalize_image_edit_multipart_data( + def finalize_image_edit_request_data( self, data: dict, resolved_request_url: str ) -> dict: """ - Adjust non-file form fields after ``transform_image_edit_request`` using the - exact URL that will be used for the HTTP POST (same string as ``get_complete_url``). + 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 diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index de476c2fb0..2ffc7acbfb 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5579,7 +5579,7 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, headers=headers, ) - data = image_edit_provider_config.finalize_image_edit_multipart_data( + data = image_edit_provider_config.finalize_image_edit_request_data( data, api_base ) @@ -5680,7 +5680,7 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, headers=headers, ) - data = image_edit_provider_config.finalize_image_edit_multipart_data( + data = image_edit_provider_config.finalize_image_edit_request_data( data, api_base ) 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 index d80e0aa90a..96cbd88da1 100644 --- 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 @@ -44,7 +44,7 @@ def test_azure_finalize_image_edit_strips_model_after_openai_transform(): api_base=litellm_params.api_base, litellm_params=litellm_params.model_dump(exclude_none=True), ) - data_out = config.finalize_image_edit_multipart_data(data, resolved) + 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 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 6e91fdf865..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,9 @@ 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, @@ -41,7 +44,7 @@ def test_azure_deployment_image_generation_json_body(): "images/generations?api-version=2025-04-01-preview" ) data = {"model": "my-dep", "prompt": "x", "n": 1} - out = AzureChatCompletion.azure_deployment_image_generation_json_body(api, data) + out = azure_deployment_image_generation_json_body(api, data) assert "model" not in out assert out == {"prompt": "x", "n": 1} @@ -50,7 +53,7 @@ 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 = AzureChatCompletion.azure_deployment_image_generation_json_body(api, data) + out = azure_deployment_image_generation_json_body(api, data) assert out == data