From 766b67cf0df4e99209946a60314332b4d6860675 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 4 May 2026 11:52:52 +0530 Subject: [PATCH] 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