From 808787659b05ac986a01892938e7387fe88d1a46 Mon Sep 17 00:00:00 2001 From: Mihidum Hettiyahandi <55163074+mihidumh@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:42:08 +1000 Subject: [PATCH 1/5] fix(azure_ai): stop accepting MAI image params the endpoint cannot honour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two params were advertised for the MAI image models and dropped downstream, so the caller got a 200 that did not match the request, or an opaque provider 400. n: get_supported_openai_params returns ["n", "size"], so n passes validation and is forwarded. The MAI endpoint (/mai/v1/images/generations) has no count field at all — its documented body is model/prompt/width/height, plus image for edits — and ignores both `n` and the native `sampleCount`. Measured against MAI-Image-2.5 and MAI-Image-2.5-Flash: n=2 and n=4 each return HTTP 200 with exactly one image, billed as one, with nothing in the response saying the request was reduced. A caller balancing cost against image count cannot see it. n=1 still passes through; n>1 now raises unless drop_params is set, which is the existing opt-in for silently dropping a param. size: _map_size_param's table offered five sizes, of which one is usable. MAI requires width and height >= 768px and width*height <= 1048576, so 512x512 and 256x256 are under the per-side minimum and 1792x1024 / 1024x1792 are over the pixel budget — all four 400 at the provider with "Model does not support request parameter value supplied: 'width' must be at least 768 pixels." Only 1024x1024 works. The bounds are now checked where the size is mapped, so the error names the constraint instead of arriving from Azure. width/height are deliberately left unchecked: they pass through unmapped, so a future MAI model with different bounds stays reachable without a code change. Verified on a live Azure AI Foundry deployment of MAI-Image-2.5 and MAI-Image-2.5-Flash (2026-08-17). One existing test asserted the 1792x1024 mapping; its size is changed to a size the provider accepts, keeping what it was testing. Co-Authored-By: Claude Opus 5 --- .../image_generation/mai_transformation.py | 49 +++++++- .../test_mai_image_generation.py | 106 +++++++++++++----- 2 files changed, 122 insertions(+), 33 deletions(-) diff --git a/litellm/llms/azure_ai/image_generation/mai_transformation.py b/litellm/llms/azure_ai/image_generation/mai_transformation.py index 64f81956ad7..3421cc0a4a1 100644 --- a/litellm/llms/azure_ai/image_generation/mai_transformation.py +++ b/litellm/llms/azure_ai/image_generation/mai_transformation.py @@ -21,6 +21,19 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): DEFAULT_WIDTH = 1024 DEFAULT_HEIGHT = 1024 + # The MAI endpoint produces exactly one image per request. Its documented + # body is model/prompt/width/height (plus `image` for edits) — there is no + # count field, and `n` (or the native `sampleCount`) is accepted and + # ignored, so a request for more silently comes back with one. + MAX_IMAGES_PER_REQUEST: Final = 1 + + # Provider-side bounds on the generated image. Both are enforced by the + # MAI endpoint, which 400s with "'width' must be at least 768 pixels." + # Only `size` is checked against them: `width`/`height` pass through + # unmapped, which keeps a future model with different bounds reachable. + MIN_DIMENSION_PX: Final = 768 + MAX_TOTAL_PX: Final = 1024 * 1024 + @staticmethod def get_mai_image_generation_url( api_base: str | None, @@ -146,6 +159,15 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): if k in supported_params: if k == "size" and v: self._map_size_param(v, optional_params) + elif k == "n" and v is not None and v > self.MAX_IMAGES_PER_REQUEST: + if not drop_params: + raise ValueError( + f"n={v} is not supported for model {model}. The Azure AI MAI image " + f"endpoint returns exactly {self.MAX_IMAGES_PER_REQUEST} image per " + "request and ignores any count, so a larger value would silently " + "return fewer images than requested. Send one request per image, or " + "set drop_params=True to drop n." + ) else: optional_params[k] = v elif k in ("width", "height"): @@ -176,13 +198,9 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): if size in size_mapping: width, height = size_mapping[size] - optional_params["width"] = width - optional_params["height"] = height elif "x" in size: try: width, height = map(int, size.lower().split("x")) - optional_params["width"] = width - optional_params["height"] = height except ValueError: raise ValueError(f"Invalid size format: '{size}'. Expected format 'WIDTHxHEIGHT' (e.g., '1024x1024').") else: @@ -191,6 +209,29 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): f"Use a known size (e.g., '1024x1024') or a custom 'WIDTHxHEIGHT' string." ) + self._validate_dimensions(size=size, width=width, height=height) + optional_params["width"] = width + optional_params["height"] = height + + def _validate_dimensions(self, size: str, width: int, height: int) -> None: + """Reject a `size` the MAI endpoint would 400 on. + + Several OpenAI-standard sizes are outside MAI's bounds: 512x512 and + 256x256 fall under the per-side minimum, and 1792x1024 / 1024x1792 + exceed the total pixel budget. Checking here turns an opaque provider + 400 into an error that names the constraint. + """ + if width < self.MIN_DIMENSION_PX or height < self.MIN_DIMENSION_PX: + raise ValueError( + f"Unsupported size value: '{size}'. Azure AI MAI image models require width and " + f"height of at least {self.MIN_DIMENSION_PX} pixels." + ) + if width * height > self.MAX_TOTAL_PX: + raise ValueError( + f"Unsupported size value: '{size}'. Azure AI MAI image models accept at most " + f"{self.MAX_TOTAL_PX} total pixels ({width}x{height} is {width * height})." + ) + def transform_image_generation_response( self, model: str, diff --git a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py index 2a44e77ce09..6cbc79fff2b 100644 --- a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py +++ b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py @@ -1,10 +1,8 @@ -import os from unittest.mock import MagicMock import httpx import pytest - import litellm from litellm.llms.azure.azure import AzureChatCompletion from litellm.llms.azure.image_generation import get_azure_image_generation_config @@ -30,9 +28,7 @@ from litellm.utils import get_optional_params_image_gen class TestAzureMAIImageGeneration: def test_is_mai_model(self): assert AzureFoundryMAIImageGenerationConfig.is_mai_model("MAI-Image-2.5") - assert AzureFoundryMAIImageGenerationConfig.is_mai_model( - "azure_ai/MAI-Image-2.5" - ) + assert AzureFoundryMAIImageGenerationConfig.is_mai_model("azure_ai/MAI-Image-2.5") assert AzureFoundryMAIImageGenerationConfig.is_mai_model("MAI-Image-2.5-Flash") assert AzureFoundryMAIImageGenerationConfig.is_mai_model("MAI-Image-2e") assert not AzureFoundryMAIImageGenerationConfig.is_mai_model("flux.2-pro") @@ -62,16 +58,10 @@ class TestAzureMAIImageGeneration: api_base="https://my-resource.services.ai.azure.com", api_version="preview", ) - assert ( - url - == "https://my-resource.services.ai.azure.com/mai/v1/images/generations?api-version=preview" - ) + assert url == "https://my-resource.services.ai.azure.com/mai/v1/images/generations?api-version=preview" def test_get_mai_image_generation_url_preserves_full_path(self): - api = ( - "https://my-resource.services.ai.azure.com/mai/v1/images/generations" - "?api-version=preview" - ) + api = "https://my-resource.services.ai.azure.com/mai/v1/images/generations?api-version=preview" url = AzureFoundryMAIImageGenerationConfig.get_mai_image_generation_url( api_base=api, api_version="preview", @@ -83,10 +73,7 @@ class TestAzureMAIImageGeneration: api_base="https://my-resource.services.ai.azure.com/mai/v1", api_version="preview", ) - assert ( - url - == "https://my-resource.services.ai.azure.com/mai/v1/images/generations?api-version=preview" - ) + assert url == "https://my-resource.services.ai.azure.com/mai/v1/images/generations?api-version=preview" def test_get_azure_ai_image_generation_config_returns_mai(self): config = get_azure_ai_image_generation_config("MAI-Image-2.5") @@ -124,13 +111,13 @@ class TestAzureMAIImageGeneration: config = AzureFoundryMAIImageGenerationConfig() optional_params = get_optional_params_image_gen( model="MAI-Image-2.5", - size="1792x1024", + size="1024x1024", n=1, custom_llm_provider="azure_ai", provider_config=config, drop_params=True, ) - assert optional_params["width"] == 1792 + assert optional_params["width"] == 1024 assert optional_params["height"] == 1024 assert "size" not in optional_params @@ -147,10 +134,7 @@ class TestAzureMAIImageGeneration: assert "api-version=preview" in url def test_mai_json_body_keeps_model(self): - api = ( - "https://my-resource.services.ai.azure.com/mai/v1/images/generations" - "?api-version=preview" - ) + api = "https://my-resource.services.ai.azure.com/mai/v1/images/generations?api-version=preview" data = { "model": "MAI-Image-2.5", "prompt": "A photograph of a red fox", @@ -214,6 +198,74 @@ class TestAzureMAIImageGeneration: drop_params=True, ) + @pytest.mark.parametrize("size", ["512x512", "256x256", "700x1400"]) + def test_map_openai_params_size_below_minimum_dimension_raises(self, size): + """MAI requires >= 768px per side; the OpenAI size table offered smaller ones.""" + config = AzureFoundryMAIImageGenerationConfig() + with pytest.raises(ValueError, match="at least 768 pixels"): + config.map_openai_params( + non_default_params={"size": size}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=True, + ) + + @pytest.mark.parametrize("size", ["1792x1024", "1024x1792"]) + def test_map_openai_params_size_over_total_pixel_budget_raises(self, size): + """MAI caps total pixels at 1024*1024, so both landscape/portrait sizes 400 upstream.""" + config = AzureFoundryMAIImageGenerationConfig() + with pytest.raises(ValueError, match="at most 1048576 total pixels"): + config.map_openai_params( + non_default_params={"size": size}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=True, + ) + + def test_map_openai_params_explicit_width_height_not_range_checked(self): + """width/height pass through unmapped, so a future model's bounds stay reachable.""" + config = AzureFoundryMAIImageGenerationConfig() + optional_params = config.map_openai_params( + non_default_params={"width": 1792, "height": 1024}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=True, + ) + assert optional_params["width"] == 1792 + assert optional_params["height"] == 1024 + + @pytest.mark.parametrize("n", [2, 4]) + def test_map_openai_params_multi_image_n_raises(self, n): + """The MAI endpoint returns one image and ignores any count, so n>1 must not pass silently.""" + config = AzureFoundryMAIImageGenerationConfig() + with pytest.raises(ValueError, match="returns exactly 1 image per request"): + config.map_openai_params( + non_default_params={"n": n}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=False, + ) + + def test_map_openai_params_multi_image_n_dropped_with_drop_params(self): + config = AzureFoundryMAIImageGenerationConfig() + optional_params = config.map_openai_params( + non_default_params={"n": 4}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=True, + ) + assert "n" not in optional_params + + def test_map_openai_params_single_image_n_still_passes_through(self): + config = AzureFoundryMAIImageGenerationConfig() + optional_params = config.map_openai_params( + non_default_params={"n": 1}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=False, + ) + assert optional_params["n"] == 1 + def test_map_openai_params_unsupported_param_raises(self): config = AzureFoundryMAIImageGenerationConfig() with pytest.raises(ValueError, match="Parameter quality is not supported"): @@ -363,16 +415,12 @@ class TestAzureMAIImageGeneration: litellm.model_cost = litellm.get_model_cost_map(url="") model = "azure_ai/MAI-Image-2.5" model_info = litellm.get_model_info(model=model, custom_llm_provider="azure_ai") - image_response = ImageResponse( - data=[ImageObject(b64_json="img1"), ImageObject(b64_json="img2")] - ) + image_response = ImageResponse(data=[ImageObject(b64_json="img1"), ImageObject(b64_json="img2")]) cost = azure_ai_image_cost_calculator( model=model, image_response=image_response, ) - assert ( - cost == len(image_response.data or []) * model_info["output_cost_per_image"] - ) + assert cost == len(image_response.data or []) * model_info["output_cost_per_image"] assert cost > 0 From 84a99dfc3188fdefbf9c39abc87b7621d126328e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:24:51 -0700 Subject: [PATCH 2/5] fix(azure_ai): surface rejected MAI image params as 400 and drop comments --- .../image_generation/mai_transformation.py | 60 +++++++++---------- .../test_mai_image_generation.py | 31 ++++++---- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/litellm/llms/azure_ai/image_generation/mai_transformation.py b/litellm/llms/azure_ai/image_generation/mai_transformation.py index 3421cc0a4a1..2d9d3246b4c 100644 --- a/litellm/llms/azure_ai/image_generation/mai_transformation.py +++ b/litellm/llms/azure_ai/image_generation/mai_transformation.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any, Final import httpx +from litellm.exceptions import UnsupportedParamsError from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) @@ -21,16 +22,7 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): DEFAULT_WIDTH = 1024 DEFAULT_HEIGHT = 1024 - # The MAI endpoint produces exactly one image per request. Its documented - # body is model/prompt/width/height (plus `image` for edits) — there is no - # count field, and `n` (or the native `sampleCount`) is accepted and - # ignored, so a request for more silently comes back with one. MAX_IMAGES_PER_REQUEST: Final = 1 - - # Provider-side bounds on the generated image. Both are enforced by the - # MAI endpoint, which 400s with "'width' must be at least 768 pixels." - # Only `size` is checked against them: `width`/`height` pass through - # unmapped, which keeps a future model with different bounds reachable. MIN_DIMENSION_PX: Final = 768 MAX_TOTAL_PX: Final = 1024 * 1024 @@ -158,25 +150,27 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): if k in supported_params: if k == "size" and v: - self._map_size_param(v, optional_params) - elif k == "n" and v is not None and v > self.MAX_IMAGES_PER_REQUEST: + self._map_size_param(v, optional_params, model) + elif k == "n" and v is not None and int(v) > self.MAX_IMAGES_PER_REQUEST: if not drop_params: - raise ValueError( + raise self._unsupported( + model, f"n={v} is not supported for model {model}. The Azure AI MAI image " f"endpoint returns exactly {self.MAX_IMAGES_PER_REQUEST} image per " "request and ignores any count, so a larger value would silently " "return fewer images than requested. Send one request per image, or " - "set drop_params=True to drop n." + "set drop_params=True to drop n.", ) else: optional_params[k] = v elif k in ("width", "height"): optional_params[k] = v elif not drop_params: - raise ValueError( + raise self._unsupported( + model, f"Parameter {k} is not supported for model {model}. " f"Supported parameters are {supported_params} and width/height. " - f"Set drop_params=True to drop unsupported parameters." + f"Set drop_params=True to drop unsupported parameters.", ) if "width" not in optional_params: @@ -187,7 +181,11 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): optional_params.pop("size", None) return optional_params - def _map_size_param(self, size: str, optional_params: dict) -> None: + @staticmethod + def _unsupported(model: str, message: str) -> UnsupportedParamsError: + return UnsupportedParamsError(message=message, llm_provider="azure_ai", model=model) + + def _map_size_param(self, size: str, optional_params: dict, model: str) -> None: size_mapping: Final = { "1024x1024": (1024, 1024), "1792x1024": (1792, 1024), @@ -202,34 +200,32 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): try: width, height = map(int, size.lower().split("x")) except ValueError: - raise ValueError(f"Invalid size format: '{size}'. Expected format 'WIDTHxHEIGHT' (e.g., '1024x1024').") + raise self._unsupported( + model, f"Invalid size format: '{size}'. Expected format 'WIDTHxHEIGHT' (e.g., '1024x1024')." + ) else: - raise ValueError( + raise self._unsupported( + model, f"Unsupported size value: '{size}'. " - f"Use a known size (e.g., '1024x1024') or a custom 'WIDTHxHEIGHT' string." + f"Use a known size (e.g., '1024x1024') or a custom 'WIDTHxHEIGHT' string.", ) - self._validate_dimensions(size=size, width=width, height=height) + self._validate_dimensions(model=model, size=size, width=width, height=height) optional_params["width"] = width optional_params["height"] = height - def _validate_dimensions(self, size: str, width: int, height: int) -> None: - """Reject a `size` the MAI endpoint would 400 on. - - Several OpenAI-standard sizes are outside MAI's bounds: 512x512 and - 256x256 fall under the per-side minimum, and 1792x1024 / 1024x1792 - exceed the total pixel budget. Checking here turns an opaque provider - 400 into an error that names the constraint. - """ + def _validate_dimensions(self, model: str, size: str, width: int, height: int) -> None: if width < self.MIN_DIMENSION_PX or height < self.MIN_DIMENSION_PX: - raise ValueError( + raise self._unsupported( + model, f"Unsupported size value: '{size}'. Azure AI MAI image models require width and " - f"height of at least {self.MIN_DIMENSION_PX} pixels." + f"height of at least {self.MIN_DIMENSION_PX} pixels.", ) if width * height > self.MAX_TOTAL_PX: - raise ValueError( + raise self._unsupported( + model, f"Unsupported size value: '{size}'. Azure AI MAI image models accept at most " - f"{self.MAX_TOTAL_PX} total pixels ({width}x{height} is {width * height})." + f"{self.MAX_TOTAL_PX} total pixels ({width}x{height} is {width * height}).", ) def transform_image_generation_response( diff --git a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py index 6cbc79fff2b..29aa5967c9a 100644 --- a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py +++ b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py @@ -4,6 +4,7 @@ import httpx import pytest import litellm +from litellm.exceptions import UnsupportedParamsError from litellm.llms.azure.azure import AzureChatCompletion from litellm.llms.azure.image_generation import get_azure_image_generation_config from litellm.llms.azure.image_generation.http_utils import ( @@ -180,7 +181,7 @@ class TestAzureMAIImageGeneration: def test_map_openai_params_unsupported_size_raises(self): config = AzureFoundryMAIImageGenerationConfig() - with pytest.raises(ValueError, match="Unsupported size value: 'auto'"): + with pytest.raises(UnsupportedParamsError, match="Unsupported size value: 'auto'"): config.map_openai_params( non_default_params={"size": "auto"}, optional_params={}, @@ -190,7 +191,7 @@ class TestAzureMAIImageGeneration: def test_map_openai_params_invalid_custom_size_raises(self): config = AzureFoundryMAIImageGenerationConfig() - with pytest.raises(ValueError, match="Invalid size format: '1024xabc'"): + with pytest.raises(UnsupportedParamsError, match="Invalid size format: '1024xabc'"): config.map_openai_params( non_default_params={"size": "1024xabc"}, optional_params={}, @@ -200,9 +201,8 @@ class TestAzureMAIImageGeneration: @pytest.mark.parametrize("size", ["512x512", "256x256", "700x1400"]) def test_map_openai_params_size_below_minimum_dimension_raises(self, size): - """MAI requires >= 768px per side; the OpenAI size table offered smaller ones.""" config = AzureFoundryMAIImageGenerationConfig() - with pytest.raises(ValueError, match="at least 768 pixels"): + with pytest.raises(UnsupportedParamsError, match="at least 768 pixels"): config.map_openai_params( non_default_params={"size": size}, optional_params={}, @@ -212,9 +212,8 @@ class TestAzureMAIImageGeneration: @pytest.mark.parametrize("size", ["1792x1024", "1024x1792"]) def test_map_openai_params_size_over_total_pixel_budget_raises(self, size): - """MAI caps total pixels at 1024*1024, so both landscape/portrait sizes 400 upstream.""" config = AzureFoundryMAIImageGenerationConfig() - with pytest.raises(ValueError, match="at most 1048576 total pixels"): + with pytest.raises(UnsupportedParamsError, match="at most 1048576 total pixels"): config.map_openai_params( non_default_params={"size": size}, optional_params={}, @@ -223,7 +222,6 @@ class TestAzureMAIImageGeneration: ) def test_map_openai_params_explicit_width_height_not_range_checked(self): - """width/height pass through unmapped, so a future model's bounds stay reachable.""" config = AzureFoundryMAIImageGenerationConfig() optional_params = config.map_openai_params( non_default_params={"width": 1792, "height": 1024}, @@ -234,11 +232,10 @@ class TestAzureMAIImageGeneration: assert optional_params["width"] == 1792 assert optional_params["height"] == 1024 - @pytest.mark.parametrize("n", [2, 4]) + @pytest.mark.parametrize("n", [2, 4, "2"]) def test_map_openai_params_multi_image_n_raises(self, n): - """The MAI endpoint returns one image and ignores any count, so n>1 must not pass silently.""" config = AzureFoundryMAIImageGenerationConfig() - with pytest.raises(ValueError, match="returns exactly 1 image per request"): + with pytest.raises(UnsupportedParamsError, match="returns exactly 1 image per request"): config.map_openai_params( non_default_params={"n": n}, optional_params={}, @@ -266,9 +263,21 @@ class TestAzureMAIImageGeneration: ) assert optional_params["n"] == 1 + @pytest.mark.parametrize("params", [{"n": 2}, {"size": "512x512"}, {"size": "1792x1024"}]) + def test_image_generation_rejected_params_surface_as_400(self, params): + with pytest.raises(litellm.BadRequestError) as exc_info: + litellm.image_generation( + model="azure_ai/MAI-Image-2.5", + prompt="A photograph of a red fox", + api_key="test-key", + api_base="https://my-resource.services.ai.azure.com", + **params, + ) + assert exc_info.value.status_code == 400 + def test_map_openai_params_unsupported_param_raises(self): config = AzureFoundryMAIImageGenerationConfig() - with pytest.raises(ValueError, match="Parameter quality is not supported"): + with pytest.raises(UnsupportedParamsError, match="Parameter quality is not supported"): config.map_openai_params( non_default_params={"quality": "hd"}, optional_params={}, From 38a3de764154a600441a37c746d78c7ca89d57bf Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:46:18 -0700 Subject: [PATCH 3/5] fix(azure_ai): use the pixel cap the live MAI endpoint enforces --- .../image_generation/mai_transformation.py | 2 +- .../test_mai_image_generation.py | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/litellm/llms/azure_ai/image_generation/mai_transformation.py b/litellm/llms/azure_ai/image_generation/mai_transformation.py index 2d9d3246b4c..7f7a1044689 100644 --- a/litellm/llms/azure_ai/image_generation/mai_transformation.py +++ b/litellm/llms/azure_ai/image_generation/mai_transformation.py @@ -24,7 +24,7 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): MAX_IMAGES_PER_REQUEST: Final = 1 MIN_DIMENSION_PX: Final = 768 - MAX_TOTAL_PX: Final = 1024 * 1024 + MAX_TOTAL_PX: Final = 1_056_768 @staticmethod def get_mai_image_generation_url( diff --git a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py index 29aa5967c9a..a0bc3fb00b7 100644 --- a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py +++ b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py @@ -213,7 +213,7 @@ class TestAzureMAIImageGeneration: @pytest.mark.parametrize("size", ["1792x1024", "1024x1792"]) def test_map_openai_params_size_over_total_pixel_budget_raises(self, size): config = AzureFoundryMAIImageGenerationConfig() - with pytest.raises(UnsupportedParamsError, match="at most 1048576 total pixels"): + with pytest.raises(UnsupportedParamsError, match="at most 1056768 total pixels"): config.map_openai_params( non_default_params={"size": size}, optional_params={}, @@ -221,6 +221,27 @@ class TestAzureMAIImageGeneration: drop_params=True, ) + @pytest.mark.parametrize("size", ["1032x1024", "1376x768"]) + def test_map_openai_params_size_at_live_pixel_cap_passes_through(self, size): + config = AzureFoundryMAIImageGenerationConfig() + optional_params = config.map_openai_params( + non_default_params={"size": size}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=False, + ) + assert optional_params["width"] * optional_params["height"] == 1_056_768 + + def test_map_openai_params_size_one_pixel_over_live_cap_raises(self): + config = AzureFoundryMAIImageGenerationConfig() + with pytest.raises(UnsupportedParamsError, match="at most 1056768 total pixels"): + config.map_openai_params( + non_default_params={"size": "1033x1024"}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=False, + ) + def test_map_openai_params_explicit_width_height_not_range_checked(self): config = AzureFoundryMAIImageGenerationConfig() optional_params = config.map_openai_params( From 8e3052ff650460d8ba226381446c6a9b95e87cd8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:20:57 -0700 Subject: [PATCH 4/5] fix(azure_ai): honour global drop_params for image params MAI cannot serve get_optional_params_image_gen only forwarded the per-call drop_params flag to provider configs, so litellm_settings drop_params: true never dropped the n the MAI generations endpoint ignores. The MAI edits config also advertised and forwarded size, which that endpoint ignores. Non-numeric and non-positive n now surface as a 400 instead of a 500 or a pass-through. --- .../azure_ai/image_edit/mai_transformation.py | 62 +---------------- .../image_generation/mai_transformation.py | 10 ++- litellm/utils.py | 2 +- .../test_mai_image_edit_transformation.py | 67 ++++++++++--------- .../test_mai_image_generation.py | 38 ++++++++++- 5 files changed, 83 insertions(+), 96 deletions(-) diff --git a/litellm/llms/azure_ai/image_edit/mai_transformation.py b/litellm/llms/azure_ai/image_edit/mai_transformation.py index e639c20292b..55b179e9591 100644 --- a/litellm/llms/azure_ai/image_edit/mai_transformation.py +++ b/litellm/llms/azure_ai/image_edit/mai_transformation.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, Final import httpx from httpx._types import RequestFiles @@ -13,7 +13,6 @@ from litellm.llms.azure_ai.image_generation.mai_transformation import ( from litellm.llms.openai.common_utils import OpenAIError from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig from litellm.secret_managers.main import get_secret_str -from litellm.types.images.main import ImageEditOptionalRequestParams from litellm.types.llms.openai import FileTypes from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import ImageResponse @@ -26,65 +25,8 @@ if TYPE_CHECKING: class AzureFoundryMAIImageEditConfig(OpenAIImageEditConfig): """Azure AI Foundry MAI image editing (e.g. MAI-Image-2.5).""" - DEFAULT_SIZE = "1024x1024" - def get_supported_openai_params(self, model: str) -> list: - return ["prompt", "image", "model", "n", "size"] - - def map_openai_params( - self, - image_edit_optional_params: ImageEditOptionalRequestParams, - model: str, - drop_params: bool, - ) -> dict: - optional_params: Final[dict[str, Any]] = {} - supported_params: Final = self.get_supported_openai_params(model) - - for key, value in dict(image_edit_optional_params).items(): - if value is None or key in optional_params: - continue - - if key in supported_params: - if key == "size" and value: - size_param = cast(str, value) - self._validate_size_param(size_param) - optional_params[key] = size_param - else: - optional_params[key] = value - elif not drop_params: - raise ValueError( - f"Parameter {key} is not supported for model {model}. " - f"Supported parameters are {supported_params}. " - f"Set drop_params=True to drop unsupported parameters." - ) - - if "size" not in optional_params: - optional_params["size"] = self.DEFAULT_SIZE - - return optional_params - - def _validate_size_param(self, size: str) -> None: - known_sizes: Final = { - "1024x1024", - "1792x1024", - "1024x1792", - "512x512", - "256x256", - } - - if size in known_sizes: - return - - if "x" in size: - try: - tuple(map(int, size.lower().split("x", 1))) - return - except ValueError: - raise ValueError(f"Invalid size format: '{size}'. Expected format 'WIDTHxHEIGHT' (e.g., '1024x1024').") - - raise ValueError( - f"Unsupported size value: '{size}'. Use a known size (e.g., '1024x1024') or a custom 'WIDTHxHEIGHT' string." - ) + return ["prompt", "image", "model", "n"] def validate_environment( self, diff --git a/litellm/llms/azure_ai/image_generation/mai_transformation.py b/litellm/llms/azure_ai/image_generation/mai_transformation.py index 7f7a1044689..67b1a8bcab3 100644 --- a/litellm/llms/azure_ai/image_generation/mai_transformation.py +++ b/litellm/llms/azure_ai/image_generation/mai_transformation.py @@ -151,7 +151,7 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): if k in supported_params: if k == "size" and v: self._map_size_param(v, optional_params, model) - elif k == "n" and v is not None and int(v) > self.MAX_IMAGES_PER_REQUEST: + elif k == "n" and v is not None and self._image_count(v, model) != self.MAX_IMAGES_PER_REQUEST: if not drop_params: raise self._unsupported( model, @@ -185,6 +185,14 @@ class AzureFoundryMAIImageGenerationConfig(BaseImageGenerationConfig): def _unsupported(model: str, message: str) -> UnsupportedParamsError: return UnsupportedParamsError(message=message, llm_provider="azure_ai", model=model) + def _image_count(self, n: object, model: str) -> int: + if isinstance(n, int): + return n + try: + return int(str(n)) + except ValueError: + raise self._unsupported(model, f"n={n!r} is not a whole number of images for model {model}.") + def _map_size_param(self, size: str, optional_params: dict, model: str) -> None: size_mapping: Final = { "1024x1024": (1024, 1024), diff --git a/litellm/utils.py b/litellm/utils.py index bc2f4a86f12..63ad4bdd945 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -3396,7 +3396,7 @@ def get_optional_params_image_gen( non_default_params=non_default_params, optional_params=optional_params, model=model or "", - drop_params=drop_params if drop_params is not None else False, + drop_params=litellm.drop_params is True or drop_params is True, ) elif ( custom_llm_provider == "openai" diff --git a/tests/test_litellm/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py b/tests/test_litellm/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py index 284a912d9a4..75e046825a3 100644 --- a/tests/test_litellm/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py +++ b/tests/test_litellm/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py @@ -6,6 +6,7 @@ import pytest import litellm +from litellm.images.utils import ImageEditRequestUtils from litellm.llms.azure_ai.image_edit import ( AzureFoundryMAIImageEditConfig, get_azure_ai_image_edit_config, @@ -70,44 +71,48 @@ class TestAzureMAIImageEdit: assert "/mai/v1/images/edits" in url assert "api-version=preview" in url - def test_map_openai_params_keeps_size(self): - config = AzureFoundryMAIImageEditConfig() - optional_params = config.map_openai_params( - image_edit_optional_params={"size": "1792x1024", "n": 1}, + def test_get_optional_params_image_edit_size_raises_400(self, monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + with pytest.raises(litellm.UnsupportedParamsError, match="size") as exc_info: + ImageEditRequestUtils.get_optional_params_image_edit( + model="MAI-Image-2.5", + image_edit_provider_config=AzureFoundryMAIImageEditConfig(), + image_edit_optional_params={"size": "1024x1024", "n": 1}, + ) + assert exc_info.value.status_code == 400 + + def test_get_optional_params_image_edit_size_dropped_with_drop_params(self, monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + optional_params = ImageEditRequestUtils.get_optional_params_image_edit( model="MAI-Image-2.5", + image_edit_provider_config=AzureFoundryMAIImageEditConfig(), + image_edit_optional_params={"size": "1024x1024", "n": 1}, drop_params=True, ) - assert optional_params["size"] == "1792x1024" + assert "size" not in optional_params assert optional_params["n"] == 1 - assert "width" not in optional_params - assert "height" not in optional_params - def test_map_openai_params_defaults_size(self): - config = AzureFoundryMAIImageEditConfig() - optional_params = config.map_openai_params( - image_edit_optional_params={}, + def test_get_optional_params_image_edit_without_size_forwards_nothing_extra(self, monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + optional_params = ImageEditRequestUtils.get_optional_params_image_edit( model="MAI-Image-2.5", - drop_params=True, + image_edit_provider_config=AzureFoundryMAIImageEditConfig(), + image_edit_optional_params={}, ) - assert optional_params["size"] == "1024x1024" + assert optional_params == {} - def test_map_openai_params_unsupported_size_raises(self): - config = AzureFoundryMAIImageEditConfig() - with pytest.raises(ValueError, match="Unsupported size value: 'auto'"): - config.map_openai_params( - image_edit_optional_params={"size": "auto"}, - model="MAI-Image-2.5", - drop_params=True, - ) - - def test_map_openai_params_invalid_size_format_raises(self): - config = AzureFoundryMAIImageEditConfig() - with pytest.raises(ValueError, match="Invalid size format: '1024xabc'"): - config.map_openai_params( - image_edit_optional_params={"size": "1024xabc"}, - model="MAI-Image-2.5", - drop_params=True, + def test_image_edit_size_surfaces_as_400(self, monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + with pytest.raises(litellm.BadRequestError) as exc_info: + litellm.image_edit( + model="azure_ai/MAI-Image-2.5", + image=io.BytesIO(b"fake-image-bytes"), + prompt="Turn this into a studio product shot", + size="1024x1024", + api_key="test-key", + api_base="https://my-resource.services.ai.azure.com", ) + assert exc_info.value.status_code == 400 def test_transform_image_edit_request_uses_image_field(self): config = AzureFoundryMAIImageEditConfig() @@ -117,14 +122,14 @@ class TestAzureMAIImageEdit: model="MAI-Image-2.5", prompt="Turn this into a studio product shot", image=image_bytes, - image_edit_optional_request_params={"size": "1024x1024", "n": 1}, + image_edit_optional_request_params={"n": 1}, litellm_params={}, headers={}, ) assert data["model"] == "MAI-Image-2.5" assert data["prompt"] == "Turn this into a studio product shot" - assert data["size"] == "1024x1024" + assert "size" not in data assert data["n"] == 1 assert len(files) == 1 assert files[0][0] == "image" diff --git a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py index a0bc3fb00b7..b8632a7ca5c 100644 --- a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py +++ b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py @@ -253,8 +253,8 @@ class TestAzureMAIImageGeneration: assert optional_params["width"] == 1792 assert optional_params["height"] == 1024 - @pytest.mark.parametrize("n", [2, 4, "2"]) - def test_map_openai_params_multi_image_n_raises(self, n): + @pytest.mark.parametrize("n", [2, 4, "2", 0, -1]) + def test_map_openai_params_n_other_than_one_raises(self, n): config = AzureFoundryMAIImageGenerationConfig() with pytest.raises(UnsupportedParamsError, match="returns exactly 1 image per request"): config.map_openai_params( @@ -264,6 +264,38 @@ class TestAzureMAIImageGeneration: drop_params=False, ) + def test_map_openai_params_non_numeric_n_raises_400(self): + config = AzureFoundryMAIImageGenerationConfig() + with pytest.raises(UnsupportedParamsError, match="not a whole number of images") as exc_info: + config.map_openai_params( + non_default_params={"n": "abc"}, + optional_params={}, + model="MAI-Image-2.5", + drop_params=False, + ) + assert exc_info.value.status_code == 400 + + def test_get_optional_params_image_gen_global_drop_params_drops_multi_image_n(self, monkeypatch): + monkeypatch.setattr(litellm, "drop_params", True) + optional_params = get_optional_params_image_gen( + model="MAI-Image-2.5", + n=4, + custom_llm_provider="azure_ai", + provider_config=AzureFoundryMAIImageGenerationConfig(), + ) + assert "n" not in optional_params + assert optional_params["width"] == 1024 + + def test_get_optional_params_image_gen_without_any_drop_params_still_raises(self, monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + with pytest.raises(UnsupportedParamsError, match="returns exactly 1 image per request"): + get_optional_params_image_gen( + model="MAI-Image-2.5", + n=4, + custom_llm_provider="azure_ai", + provider_config=AzureFoundryMAIImageGenerationConfig(), + ) + def test_map_openai_params_multi_image_n_dropped_with_drop_params(self): config = AzureFoundryMAIImageGenerationConfig() optional_params = config.map_openai_params( @@ -284,7 +316,7 @@ class TestAzureMAIImageGeneration: ) assert optional_params["n"] == 1 - @pytest.mark.parametrize("params", [{"n": 2}, {"size": "512x512"}, {"size": "1792x1024"}]) + @pytest.mark.parametrize("params", [{"n": 2}, {"n": "abc"}, {"size": "512x512"}, {"size": "1792x1024"}]) def test_image_generation_rejected_params_surface_as_400(self, params): with pytest.raises(litellm.BadRequestError) as exc_info: litellm.image_generation( From 9c6a385efe1f61cbd9d276ed09427102aec820ff Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:57:37 -0700 Subject: [PATCH 5/5] docs(azure_ai): drop size from the MAI image edit router docstring --- litellm/llms/azure_ai/image_edit/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/azure_ai/image_edit/__init__.py b/litellm/llms/azure_ai/image_edit/__init__.py index 51a23859058..fda9335a5d6 100644 --- a/litellm/llms/azure_ai/image_edit/__init__.py +++ b/litellm/llms/azure_ai/image_edit/__init__.py @@ -23,7 +23,7 @@ def get_azure_ai_image_edit_config(model: str) -> BaseImageEditConfig: """ Get the appropriate image edit config for an Azure AI model. - - MAI models use /mai/v1/images/edits with multipart form data and size + - MAI models use /mai/v1/images/edits with multipart form data - FLUX 2 models use JSON with base64 image - FLUX 1 models use multipart/form-data """