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 <cursoragent@cursor.com>
This commit is contained in:
Sameer Kankute 2026-05-04 11:52:52 +05:30
parent c011a7e3ba
commit 766b67cf0d
No known key found for this signature in database
4 changed files with 159 additions and 30 deletions

View File

@ -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 nonOpenAI-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,
)

View File

@ -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

View File

@ -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

View File

@ -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