From 4c29e1846a7de81bc986f23058c91816be2e5581 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:50:42 +0800 Subject: [PATCH] feat(azure_ai): support gpt-image edits on Azure AI Foundry Foundry hosts gpt-image deployments on the same /openai/deployments/{deployment}/images/edits route as Azure OpenAI, but the azure_ai image-edit factory only knew MAI and FLUX, so gpt-image-2 and the gpt-image-2.5 models raised a ValueError before any request was sent Reuse AzureImageEditConfig for the request, URL, and multipart handling, and resolve the api_base and credentials with the azure_ai helpers Co-Authored-By: AaronHowell <111272993+AaronHowell@users.noreply.github.com> Co-Authored-By: Claude Code --- litellm/llms/azure_ai/image_edit/__init__.py | 9 +- .../azure_ai/image_edit/gpt_transformation.py | 57 +++++++ .../test_gpt_image_edit_transformation.py | 161 ++++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 litellm/llms/azure_ai/image_edit/gpt_transformation.py create mode 100644 tests/unit/llms/azure_ai/image_edit/test_gpt_image_edit_transformation.py diff --git a/litellm/llms/azure_ai/image_edit/__init__.py b/litellm/llms/azure_ai/image_edit/__init__.py index fda9335a5d6..5436d00a6fa 100644 --- a/litellm/llms/azure_ai/image_edit/__init__.py +++ b/litellm/llms/azure_ai/image_edit/__init__.py @@ -9,12 +9,14 @@ from litellm.llms.azure_ai.image_generation.mai_transformation import ( from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from .flux2_transformation import AzureFoundryFlux2ImageEditConfig +from .gpt_transformation import AzureFoundryGPTImageEditConfig from .mai_transformation import AzureFoundryMAIImageEditConfig from .transformation import AzureFoundryFluxImageEditConfig __all__ = [ "AzureFoundryFlux2ImageEditConfig", "AzureFoundryFluxImageEditConfig", + "AzureFoundryGPTImageEditConfig", "AzureFoundryMAIImageEditConfig", ] @@ -23,6 +25,7 @@ def get_azure_ai_image_edit_config(model: str) -> BaseImageEditConfig: """ Get the appropriate image edit config for an Azure AI model. + - gpt-image models use /openai/deployments/{deployment}/images/edits with multipart form data - 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 @@ -30,12 +33,16 @@ def get_azure_ai_image_edit_config(model: str) -> BaseImageEditConfig: if AzureFoundryMAIImageGenerationConfig.is_mai_model(model): return AzureFoundryMAIImageEditConfig() + model_normalized: Final = model.lower().replace("-", "").replace("_", "") + + if "gptimage" in model_normalized: + return AzureFoundryGPTImageEditConfig() + # Check if it's a FLUX 2 model if AzureFoundryFluxImageGenerationConfig.is_flux2_model(model): return AzureFoundryFlux2ImageEditConfig() # Default to FLUX 1 config for other FLUX models - model_normalized: Final = model.lower().replace("-", "").replace("_", "") if model_normalized == "" or "flux" in model_normalized: return AzureFoundryFluxImageEditConfig() diff --git a/litellm/llms/azure_ai/image_edit/gpt_transformation.py b/litellm/llms/azure_ai/image_edit/gpt_transformation.py new file mode 100644 index 00000000000..4b926f8224e --- /dev/null +++ b/litellm/llms/azure_ai/image_edit/gpt_transformation.py @@ -0,0 +1,57 @@ +from typing import Final + +from litellm.llms.azure.image_edit.transformation import AzureImageEditConfig +from litellm.llms.azure_ai.common_utils import ( + AzureFoundryModelInfo, + get_azure_ai_auth_headers, +) +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams + + +class AzureFoundryGPTImageEditConfig(AzureImageEditConfig): + """ + Azure AI Foundry gpt-image edit config. + + Foundry serves gpt-image deployments on the same + ``/openai/deployments/{deployment}/images/edits`` route as Azure OpenAI, so the + request and URL handling is shared with ``AzureImageEditConfig``. Only the + credential source is Foundry-specific. + """ + + def validate_environment( + self, + headers: dict[str, str], + model: str, + api_key: str | None = None, + litellm_params: dict[str, object] | None = None, + api_base: str | None = None, + ) -> dict[str, str]: + params: Final = GenericLiteLLMParams(**(litellm_params or {})) + resolved_api_key: Final = params.api_key or api_key + headers.update( + get_azure_ai_auth_headers( + api_key=AzureFoundryModelInfo.get_api_key(resolved_api_key), + litellm_params=litellm_params, + api_key_header="api-key", + ) + ) + return headers + + def get_complete_url( + self, + model: str, + api_base: str | None, + litellm_params: dict[str, object], + ) -> str: + azure_ai_api_version: Final = litellm_params.get("api_version") or get_secret_str("AZURE_AI_API_VERSION") + resolved_params: Final = ( + {**litellm_params, "api_version": azure_ai_api_version} + if azure_ai_api_version is not None + else litellm_params + ) + return super().get_complete_url( # pyright: ignore[reportUnknownMemberType] # bare dict in base + model=model, + api_base=AzureFoundryModelInfo.get_api_base(api_base), + litellm_params=resolved_params, + ) diff --git a/tests/unit/llms/azure_ai/image_edit/test_gpt_image_edit_transformation.py b/tests/unit/llms/azure_ai/image_edit/test_gpt_image_edit_transformation.py new file mode 100644 index 00000000000..9f30d26dbd1 --- /dev/null +++ b/tests/unit/llms/azure_ai/image_edit/test_gpt_image_edit_transformation.py @@ -0,0 +1,161 @@ +from typing import Final + +import httpx +import pytest + +import litellm +from litellm.llms.azure_ai.image_edit import get_azure_ai_image_edit_config +from litellm.llms.azure_ai.image_edit.gpt_transformation import ( + AzureFoundryGPTImageEditConfig, +) +from litellm.llms.custom_httpx.http_handler import HTTPHandler + +FOUNDRY_API_BASE: Final = "https://example.services.ai.azure.com" +DEPLOYMENT_EDIT_URL: Final = ( + f"{FOUNDRY_API_BASE}/openai/deployments/gpt-image-2.5-flare/images/edits?api-version=2025-04-01-preview" +) + + +@pytest.mark.parametrize( + "model", + [ + "gpt-image-1", + "gpt-image-2", + "gpt-image-2.5-flare", + "gpt-image-2.5-flare-2026-09-08", + "gpt-image-2.5-sunburst", + "gpt-image-2.5-sunburst-2026-09-08", + ], +) +def test_gpt_image_models_get_gpt_image_edit_config(model: str): + assert isinstance(get_azure_ai_image_edit_config(model), AzureFoundryGPTImageEditConfig) + + +def test_gpt_image_edit_url_uses_foundry_deployment_route(): + config: Final = AzureFoundryGPTImageEditConfig() + + url: Final = config.get_complete_url( + model="gpt-image-2.5-flare", + api_base=FOUNDRY_API_BASE, + litellm_params={"api_version": "2025-04-01-preview"}, + ) + + assert url == DEPLOYMENT_EDIT_URL + + +def test_gpt_image_edit_url_uses_azure_ai_api_base_and_version_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("AZURE_AI_API_BASE", FOUNDRY_API_BASE) + monkeypatch.setenv("AZURE_AI_API_VERSION", "2025-04-01-preview") + monkeypatch.delenv("AZURE_API_VERSION", raising=False) + monkeypatch.setattr(litellm, "api_base", None) + monkeypatch.setattr(litellm, "api_version", None) + + url: Final = AzureFoundryGPTImageEditConfig().get_complete_url( + model="gpt-image-2.5-flare", + api_base=None, + litellm_params={}, + ) + + assert url == DEPLOYMENT_EDIT_URL + + +def test_gpt_image_edit_url_uses_v1_surface_for_preview_version(): + url: Final = AzureFoundryGPTImageEditConfig().get_complete_url( + model="gpt-image-2.5-flare", + api_base=FOUNDRY_API_BASE, + litellm_params={"api_version": "preview"}, + ) + + assert url == f"{FOUNDRY_API_BASE}/openai/v1/images/edits?api-version=preview" + + +def test_gpt_image_edit_sends_api_key_header(): + headers: Final = AzureFoundryGPTImageEditConfig().validate_environment( + {}, + "gpt-image-2.5-flare", + api_key="test-key", + ) + + assert headers == {"api-key": "test-key"} + + +def test_gpt_image_edit_prefers_litellm_params_api_key(): + headers: Final = AzureFoundryGPTImageEditConfig().validate_environment( + {}, + "gpt-image-2.5-flare", + api_key="positional-key", + litellm_params={"api_key": "params-key"}, + ) + + assert headers["api-key"] == "params-key" + + +def test_gpt_image_edit_reads_azure_ai_api_key_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("AZURE_AI_API_KEY", "env-key") + monkeypatch.setattr(litellm, "api_key", None) + + headers: Final = AzureFoundryGPTImageEditConfig().validate_environment( + {}, + "gpt-image-2.5-flare", + ) + + assert headers["api-key"] == "env-key" + + +def test_gpt_image_edit_falls_back_to_entra_token(monkeypatch: pytest.MonkeyPatch, no_ambient_azure_credentials: None): + monkeypatch.delenv("AZURE_AI_API_KEY", raising=False) + monkeypatch.setattr(litellm, "api_key", None) + + headers: Final = AzureFoundryGPTImageEditConfig().validate_environment( + {}, + "gpt-image-2.5-flare", + litellm_params={"azure_ad_token": "entra-token"}, + ) + + assert headers == {"Authorization": "Bearer entra-token"} + + +def test_gpt_image_edit_strips_model_from_deployment_multipart(): + data: Final = AzureFoundryGPTImageEditConfig().finalize_image_edit_request_data( + {"model": "gpt-image-2.5-flare", "prompt": "Add a hat"}, + DEPLOYMENT_EDIT_URL, + ) + + assert data == {"prompt": "Add a hat"} + + +def test_gpt_image_edit_keeps_model_on_v1_multipart(): + data: Final = AzureFoundryGPTImageEditConfig().finalize_image_edit_request_data( + {"model": "gpt-image-2.5-flare", "prompt": "Add a hat"}, + f"{FOUNDRY_API_BASE}/openai/v1/images/edits?api-version=preview", + ) + + assert data == {"model": "gpt-image-2.5-flare", "prompt": "Add a hat"} + + +def test_gpt_image_edit_end_to_end_posts_multipart_to_deployment_route(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("AZURE_AI_API_VERSION", "2025-04-01-preview") + monkeypatch.delenv("AZURE_API_VERSION", raising=False) + captured: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"created": 1, "data": [{"b64_json": "aW1n"}]}) + + client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond))) + response: Final = litellm.image_edit( + model="azure_ai/gpt-image-2.5-flare", + image=b"image", + prompt="Add a hat", + api_key="test-key", + api_base=FOUNDRY_API_BASE, + client=client, + ) + + assert response.data[0]["b64_json"] == "aW1n" + assert len(captured) == 1 + request: Final = captured[0] + assert str(request.url) == DEPLOYMENT_EDIT_URL + assert request.headers["api-key"] == "test-key" + assert b'name="prompt"' in request.content + assert b'name="model"' not in request.content