Merge pull request #27103 from BerriAI/litellm_azure-deployment-image-body

fix(azure): omit model from deployment image gen and image edit bodies
This commit is contained in:
Sameer Kankute
2026-05-05 09:09:45 +05:30
committed by GitHub
11 changed files with 198 additions and 105 deletions
+5 -2
View File
@@ -44,6 +44,7 @@ from .common_utils import (
select_azure_base_url_or_endpoint,
)
from .image_generation import get_azure_image_generation_config
from .image_generation.http_utils import azure_deployment_image_generation_json_body
class AzureOpenAIAssistantsAPIConfig:
@@ -966,9 +967,10 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
content=json.dumps(result).encode("utf-8"),
request=httpx.Request(method="POST", url="https://api.openai.com/v1"),
)
request_json = 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 +1087,10 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
content=json.dumps(result).encode("utf-8"),
request=httpx.Request(method="POST", url="https://api.openai.com/v1"),
)
request_json = azure_deployment_image_generation_json_body(api_base, data)
return sync_handler.post(
url=api_base,
json=data,
json=request_json,
headers=headers,
)
@@ -9,6 +9,19 @@ 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 +96,8 @@ class AzureImageEditConfig(OpenAIImageEditConfig):
final_url = httpx.URL(new_url).copy_with(params=query_params)
return str(final_url)
def finalize_image_edit_request_data(
self, data: dict, resolved_request_url: str
) -> dict:
return self.azure_deployment_image_edit_form_data(data, resolved_request_url)
@@ -6,11 +6,13 @@ from litellm.llms.base_llm.image_generation.transformation import (
from .dall_e_2_transformation import AzureDallE2ImageGenerationConfig
from .dall_e_3_transformation import AzureDallE3ImageGenerationConfig
from .gpt_transformation import AzureGPTImageGenerationConfig
from .http_utils import azure_deployment_image_generation_json_body
__all__ = [
"AzureDallE2ImageGenerationConfig",
"AzureDallE3ImageGenerationConfig",
"AzureGPTImageGenerationConfig",
"azure_deployment_image_generation_json_body",
]
@@ -0,0 +1,17 @@
"""HTTP helpers for Azure OpenAI image generation (REST, not SDK)."""
def azure_deployment_image_generation_json_body(api_base: str, data: dict) -> dict:
"""
Build the JSON body for Azure OpenAI image generation POSTs.
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
@@ -102,6 +102,18 @@ class BaseImageEditConfig(ABC):
) -> Tuple[Dict, RequestFiles]:
pass
def finalize_image_edit_request_data(
self, data: dict, resolved_request_url: str
) -> dict:
"""
Last pass on the request dict after ``transform_image_edit_request``, using the
exact URL string used for the HTTP POST (same as ``get_complete_url`` output).
The handler sends this dict as ``data=`` for multipart providers or ``json=``
for JSON-only providers; default implementation returns ``data`` unchanged.
"""
return data
@abstractmethod
def transform_image_edit_response(
self,
@@ -5579,6 +5579,9 @@ class BaseLLMHTTPHandler:
litellm_params=litellm_params,
headers=headers,
)
data = image_edit_provider_config.finalize_image_edit_request_data(
data, api_base
)
## LOGGING
logging_obj.pre_call(
@@ -5677,6 +5680,9 @@ class BaseLLMHTTPHandler:
litellm_params=litellm_params,
headers=headers,
)
data = image_edit_provider_config.finalize_image_edit_request_data(
data, api_base
)
## LOGGING
logging_obj.pre_call(
@@ -1,4 +1,3 @@
{
"model": "gpt-image-1",
"prompt": "test prompt"
}
+5 -6
View File
@@ -244,7 +244,7 @@ async def test_openai_image_edit_with_bytesio():
@pytest.mark.asyncio
async def test_azure_image_edit_litellm_sdk():
"""Test Azure image edit with mocked httpx request to validate request body and URL"""
from litellm import image_edit, aimage_edit
from litellm import aimage_edit
# Mock response for Azure image edit
mock_response = {
@@ -316,12 +316,11 @@ async def test_azure_image_edit_litellm_sdk():
list(form_data.keys()) if hasattr(form_data, "keys") else "Not a dict",
)
# Validate that model and prompt are in the form data
assert "model" in form_data, "model should be in form data"
assert "prompt" in form_data, "prompt should be in form data"
# Deployment is in the URL path; Azure rejects model in multipart for this route.
assert (
form_data["model"] == "gpt-image-1"
), f"Expected model 'gpt-image-1', got {form_data['model']}"
"model" not in form_data
), "model must not be in form data for Azure /openai/deployments/.../images/edits"
assert "prompt" in form_data, "prompt should be in the form data"
assert (
prompt.strip() in form_data["prompt"]
), f"Expected prompt to contain '{prompt.strip()}'"
@@ -393,6 +393,7 @@ async def test_aiml_image_generation_with_dynamic_api_key():
@pytest.mark.asyncio
async def test_azure_image_generation_request_body():
"""Azure deployment URL selects the model; JSON body omits ``model`` (#26316)."""
from litellm import aimage_generation
test_dir = os.path.dirname(__file__)
@@ -0,0 +1,51 @@
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_finalize_image_edit_strips_model_after_openai_transform():
"""OpenAI transform still includes model; finalize uses the real request URL."""
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 data.get("model") == model
resolved = config.get_complete_url(
model=model,
api_base=litellm_params.api_base,
litellm_params=litellm_params.model_dump(exclude_none=True),
)
data_out = config.finalize_image_edit_request_data(data, resolved)
assert "model" not in data_out
assert data_out.get("prompt") == prompt
assert data_out.get("n") == 1
assert len(files) >= 1
@@ -12,6 +12,10 @@ sys.path.insert(
) # Adds the parent directory to the system path
import litellm
from litellm.llms.azure.azure import AzureChatCompletion
from litellm.llms.azure.image_generation.http_utils import (
azure_deployment_image_generation_json_body,
)
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.azure.image_generation import (
AzureDallE3ImageGenerationConfig,
get_azure_image_generation_config,
@@ -33,6 +37,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 = 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 = 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 +284,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
@@ -295,26 +316,21 @@ def test_azure_image_generation_base_model_vs_deployment_name():
optional_params = {"n": 1, "size": "1024x1024"}
# Mock the HTTP request to capture what gets sent
mock_http_response = MagicMock()
mock_http_response.status_code = 200
mock_http_response.json.return_value = {
"created": 1234567890,
"data": [{"url": "https://example.com/image.png", "revised_prompt": prompt}],
}
with patch.object(
azure_chat_completion,
"make_sync_azure_httpx_request",
return_value=MagicMock(
json=lambda: {
"created": 1234567890,
"data": [
{"url": "https://example.com/image.png", "revised_prompt": prompt}
],
}
),
) as mock_request:
# Mock logging object
HTTPHandler, "post", return_value=mock_http_response
) as mock_post:
logging_obj = MagicMock()
logging_obj.pre_call = MagicMock()
logging_obj.post_call = MagicMock()
# Call the image_generation method
response = azure_chat_completion.image_generation(
azure_chat_completion.image_generation(
prompt=prompt,
timeout=60.0,
optional_params=optional_params,
@@ -327,43 +343,31 @@ def test_azure_image_generation_base_model_vs_deployment_name():
litellm_params=litellm_params,
)
# Verify the mock was called
assert mock_request.called, "HTTP request should have been made"
# Get the call arguments
call_kwargs = mock_request.call_args.kwargs
# Verify the URL uses the deployment name (not base_model)
api_base_used = call_kwargs.get("api_base", "")
assert model in api_base_used, (
f"URL should contain deployment name '{model}', "
f"but got: {api_base_used}"
)
assert base_model not in api_base_used or base_model == model, (
assert mock_post.called, "HTTPHandler.post should be invoked"
post_kwargs = mock_post.call_args.kwargs
url_used = post_kwargs.get("url", "")
assert (
model in url_used
), f"URL should contain deployment name '{model}', but got: {url_used}"
assert base_model not in url_used or base_model == model, (
f"URL should NOT contain base_model '{base_model}' when it differs from deployment name, "
f"but got: {api_base_used}"
f"but got: {url_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')}"
)
# Verify other fields are correct
assert request_data.get("prompt") == prompt
assert request_data.get("n") == 1
assert request_data.get("size") == "1024x1024"
wire_json = post_kwargs.get("json") or {}
assert (
"model" not in wire_json
), f"Azure deployment image gen must not send 'model' in JSON body; got keys: {list(wire_json)}"
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
@@ -384,27 +388,24 @@ async def test_azure_aimage_generation_base_model_vs_deployment_name():
"api_version": api_version,
}
# Mock the HTTP request to capture what gets sent
with patch.object(
azure_chat_completion,
"make_async_azure_httpx_request",
new_callable=AsyncMock,
return_value=MagicMock(
json=lambda: {
"created": 1234567890,
"data": [
{"url": "https://example.com/image.png", "revised_prompt": prompt}
],
}
),
) as mock_request:
# Mock logging object
mock_http_response = MagicMock()
mock_http_response.status_code = 200
mock_http_response.json.return_value = {
"created": 1234567890,
"data": [{"url": "https://example.com/image.png", "revised_prompt": prompt}],
}
mock_client = MagicMock()
mock_client.post = AsyncMock(return_value=mock_http_response)
with patch(
"litellm.llms.azure.azure.get_async_httpx_client", return_value=mock_client
):
logging_obj = MagicMock()
logging_obj.pre_call = MagicMock()
logging_obj.post_call = MagicMock()
# Call the aimage_generation method
response = await azure_chat_completion.aimage_generation(
await azure_chat_completion.aimage_generation(
data=data,
model_response=None,
azure_client_params=azure_client_params,
@@ -412,30 +413,14 @@ async def test_azure_aimage_generation_base_model_vs_deployment_name():
input=[],
logging_obj=logging_obj,
headers={},
model=model, # Pass the deployment name
model=model,
timeout=60.0,
)
# Verify the mock was called
assert mock_request.called, "HTTP request should have been made"
# Get the call arguments
call_kwargs = mock_request.call_args.kwargs
# Verify the URL uses the deployment name (not base_model)
api_base_used = call_kwargs.get("api_base", "")
assert model in api_base_used, (
f"URL should contain deployment name '{model}', "
f"but got: {api_base_used}"
)
assert base_model not in api_base_used or base_model == model, (
f"URL should NOT contain base_model '{base_model}' when it differs from 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')}"
)
assert mock_client.post.called
post_kwargs = mock_client.post.call_args.kwargs
url_used = post_kwargs.get("url", "")
assert model in url_used
wire_json = post_kwargs.get("json") or {}
assert "model" not in wire_json
assert data.get("model") == base_model