feat(azure_ai): support gpt-image edits on Azure AI Foundry (#49650)

* 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 <noreply@anthropic.com>

* fix(azure_ai): default gpt-image edits to an edit-capable api-version

Foundry gpt-image edits now fall back to 2025-04-01-preview when no api_version is set, since the shared Azure default 2025-02-01-preview answers 404 on /images/edits. The config returns merged headers instead of updating the caller's dict, and the tests check the request each gpt-image model sends

* fix(azure_ai): ignore the Azure OpenAI api-version env for Foundry gpt-image edits

The proxy CLI always sets AZURE_API_VERSION to 2025-02-01-preview, which
Foundry answers with 404 on image edits. gpt-image edits now read only
litellm_params, AZURE_AI_API_VERSION, and litellm.api_version before the
2025-04-01-preview default, matching the other azure_ai edit configs

* fix(azure_ai): route custom Foundry deployments, require a Foundry api_base, and honor caller auth for gpt-image edits

A deployment name the edit factory does not recognize now uses the gpt-image route instead of raising, since Foundry deployment names are free-form. A gpt-image edit with no Foundry api_base raises like the MAI and FLUX configs instead of borrowing AZURE_API_BASE. The shared edit handler passes the call's extra_headers to validate_environment, so a caller's api-key or Authorization header is sent alone

* fix(azure_ai): keep the shared edit handler unchanged and send caller auth headers like azure_ai chat

---------

Co-authored-by: AaronHowell <111272993+AaronHowell@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Rick 2026-11-12 08:52:55 +08:00 • committed by GitHub
parent fb207a88a9
commit 72319cc350
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 262 additions and 1 deletions

View file

@ -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",
]
@ -26,6 +28,7 @@ def get_azure_ai_image_edit_config(model: str) -> BaseImageEditConfig:
- 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
- Every other deployment, gpt-image or custom-named, uses /openai/deployments/{deployment}/images/edits
"""
if AzureFoundryMAIImageGenerationConfig.is_mai_model(model):
return AzureFoundryMAIImageEditConfig()
@ -39,4 +42,4 @@ def get_azure_ai_image_edit_config(model: str) -> BaseImageEditConfig:
if model_normalized == "" or "flux" in model_normalized:
return AzureFoundryFluxImageEditConfig()
raise ValueError(f"Model {model} is not supported for Azure AI image editing.")
return AzureFoundryGPTImageEditConfig()

View file

@ -0,0 +1,65 @@
from collections.abc import Mapping
from typing import Final
import litellm
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
AZURE_AI_GPT_IMAGE_EDIT_DEFAULT_API_VERSION: Final = "2025-04-01-preview"
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 and the default api-version are Foundry-specific.
"""
def validate_environment(
self,
headers: Mapping[str, str],
model: str,
api_key: str | None = None,
litellm_params: Mapping[str, object] | None = None,
api_base: str | None = None,
) -> dict[str, str]: # mutable-ok: llm_http_handler adds extra_headers to the returned dict in place
params: Final = GenericLiteLLMParams(**(litellm_params or {}))
return {
**headers,
**get_azure_ai_auth_headers(
api_key=AzureFoundryModelInfo.get_api_key(params.api_key or api_key),
litellm_params=litellm_params,
api_key_header="api-key",
),
}
def get_complete_url(
self,
model: str,
api_base: str | None,
litellm_params: Mapping[str, object],
) -> str:
foundry_api_base: Final = AzureFoundryModelInfo.get_api_base(api_base)
if foundry_api_base is None:
raise ValueError(
"Azure AI API base is required. Set AZURE_AI_API_BASE environment variable or pass api_base parameter."
)
api_version: Final = (
litellm_params.get("api_version")
or get_secret_str("AZURE_AI_API_VERSION")
or litellm.api_version
or AZURE_AI_GPT_IMAGE_EDIT_DEFAULT_API_VERSION
)
return super().get_complete_url( # pyright: ignore[reportUnknownMemberType] # bare dict in base
model=model,
api_base=foundry_api_base,
litellm_params={**litellm_params, "api_version": api_version},
)

View file

@ -0,0 +1,193 @@
from typing import Final
import httpx
import pytest
import litellm
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"
)
def test_gpt_image_edit_url_uses_foundry_deployment_route_and_configured_version():
url: Final = AzureFoundryGPTImageEditConfig().get_complete_url(
model="gpt-image-2.5-flare",
api_base=FOUNDRY_API_BASE,
litellm_params={"api_version": "2025-06-01-preview"},
)
assert url == f"{FOUNDRY_API_BASE}/openai/deployments/gpt-image-2.5-flare/images/edits?api-version=2025-06-01-preview"
def test_gpt_image_edit_url_defaults_to_an_edit_capable_version_on_a_proxy(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("AZURE_AI_API_VERSION", raising=False)
monkeypatch.setenv("AZURE_API_VERSION", litellm.AZURE_DEFAULT_API_VERSION)
monkeypatch.setattr(litellm, "api_version", None)
url: Final = AzureFoundryGPTImageEditConfig().get_complete_url(
model="gpt-image-2.5-flare",
api_base=FOUNDRY_API_BASE,
litellm_params={},
)
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-06-01-preview")
monkeypatch.setenv("AZURE_API_VERSION", litellm.AZURE_DEFAULT_API_VERSION)
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 == f"{FOUNDRY_API_BASE}/openai/deployments/gpt-image-2.5-flare/images/edits?api-version=2025-06-01-preview"
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_without_changing_caller_headers():
caller_headers: Final = {"x-trace": "abc"}
headers: Final = AzureFoundryGPTImageEditConfig().validate_environment(
caller_headers,
"gpt-image-2.5-flare",
api_key="test-key",
)
assert headers == {"x-trace": "abc", "api-key": "test-key"}
assert caller_headers == {"x-trace": "abc"}
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 _edit_through_fake_foundry(model: str, **kwargs: object) -> httpx.Request:
captured: Final[list[httpx.Request]] = [] # mutable-ok: the fake Foundry endpoint records what litellm sent
def respond(request: httpx.Request) -> httpx.Response:
captured.append(request)
return httpx.Response(200, json={"created": 1, "data": [{"b64_json": "aW1n"}]})
response: Final = litellm.image_edit(
model=f"azure_ai/{model}",
image=b"image",
prompt="Add a hat",
api_base=FOUNDRY_API_BASE,
client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond))),
**kwargs,
)
assert response.data[0]["b64_json"] == "aW1n"
assert len(captured) == 1
return captured[0]
@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",
"my-image-editor",
],
)
def test_gpt_image_edit_end_to_end_posts_multipart_to_deployment_route(monkeypatch: pytest.MonkeyPatch, model: str):
monkeypatch.delenv("AZURE_AI_API_VERSION", raising=False)
monkeypatch.setenv("AZURE_API_VERSION", litellm.AZURE_DEFAULT_API_VERSION)
monkeypatch.setattr(litellm, "api_version", None)
request: Final = _edit_through_fake_foundry(model, api_key="test-key")
assert str(request.url) == (
f"{FOUNDRY_API_BASE}/openai/deployments/{model}/images/edits?api-version=2025-04-01-preview"
)
assert request.headers["api-key"] == "test-key"
assert b'name="prompt"' in request.content
assert b'name="model"' not in request.content
def test_gpt_image_edit_requires_a_foundry_api_base_instead_of_the_azure_openai_one(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("AZURE_AI_API_BASE", raising=False)
monkeypatch.setenv("AZURE_API_BASE", "https://other-resource.openai.azure.com")
monkeypatch.setattr(litellm, "api_base", None)
with pytest.raises(ValueError, match="Azure AI API base is required"):
AzureFoundryGPTImageEditConfig().get_complete_url(
model="gpt-image-2.5-flare",
api_base=None,
litellm_params={},
)