mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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:
parent
fb207a88a9
commit
72319cc350
3 changed files with 262 additions and 1 deletions
|
|
@ -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()
|
||||
|
|
|
|||
65
litellm/llms/azure_ai/image_edit/gpt_transformation.py
Normal file
65
litellm/llms/azure_ai/image_edit/gpt_transformation.py
Normal 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},
|
||||
)
|
||||
|
|
@ -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={},
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue