fix(azure): send the deployment name as the body model on v1 image routes

This commit is contained in:
mateo-berri 2026-08-29 11:41:58 -07:00
parent ed9575520b
commit 9e01bd1441
3 changed files with 121 additions and 6 deletions

View file

@ -846,6 +846,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
api_key: str,
data: dict,
headers: dict,
deployment_name: str | None = None,
) -> httpx.Response:
"""
Implemented for azure dall-e-2 image gen calls
@ -957,7 +958,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
content=json.dumps(result).encode("utf-8"),
request=httpx.Request(method="POST", url="https://api.openai.com/v1"),
)
request_json: Final = azure_deployment_image_generation_json_body(api_base, data)
request_json: Final = azure_deployment_image_generation_json_body(api_base, data, deployment_name)
return await async_handler.post(
url=api_base,
json=request_json,
@ -973,6 +974,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
api_key: str,
data: dict,
headers: dict,
deployment_name: str | None = None,
) -> httpx.Response:
"""
Implemented for azure dall-e-2 image gen calls
@ -1073,7 +1075,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
content=json.dumps(result).encode("utf-8"),
request=httpx.Request(method="POST", url="https://api.openai.com/v1"),
)
request_json: Final = azure_deployment_image_generation_json_body(api_base, data)
request_json: Final = azure_deployment_image_generation_json_body(api_base, data, deployment_name)
return sync_handler.post(
url=api_base,
json=request_json,
@ -1176,6 +1178,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
api_key=api_key,
data=data,
headers=headers,
deployment_name=model,
)
provider_config: Final = get_azure_image_generation_config(data.get("model", "dall-e-2"))
@ -1311,6 +1314,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
api_key=api_key or "",
data=data,
headers=headers,
deployment_name=model,
)
provider_config: Final = get_azure_image_generation_config(data.get("model", "dall-e-2"))
if isinstance(provider_config, AzureFoundryMAIImageGenerationConfig):

View file

@ -1,7 +1,9 @@
"""HTTP helpers for Azure OpenAI image generation (REST, not SDK)."""
from typing import Final
def azure_deployment_image_generation_json_body(api_base: str, data: dict) -> dict:
def azure_deployment_image_generation_json_body(api_base: str, data: dict, deployment_name: str | None = None) -> dict:
"""
Build the JSON body for Azure OpenAI image generation POSTs.
@ -9,9 +11,20 @@ def azure_deployment_image_generation_json_body(api_base: str, data: dict) -> di
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.
For the v1 surface (``.../openai/v1/images/...``), Azure routes by the deployment
name in the body ``model`` field, so the deployment name must replace any base
model name there or Azure answers 404 DeploymentNotFound.
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
drop_model: Final = "images/generations" in api_base and "/openai/deployments/" in api_base
v1_route: Final = "/openai/v1/images/" in api_base and bool(deployment_name)
if not drop_model and not v1_route:
return data
entries: Final = (
tuple((k, v) for k, v in data.items() if k != "model")
if drop_model
else (*data.items(), ("model", deployment_name))
)
return {k: v for k, v in entries}

View file

@ -3,9 +3,12 @@ import traceback
from typing import Callable, Optional
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
import respx
import litellm
from litellm.caching.llm_caching_handler import LLMClientCache
from litellm.llms.azure.azure import AzureChatCompletion
from litellm.llms.azure.image_generation.http_utils import (
azure_deployment_image_generation_json_body,
@ -489,3 +492,98 @@ def test_azure_image_generation_v1_api_version_uses_base_url_client_param():
base_model=None,
)
assert url == "https://my-resource.openai.azure.com/openai/v1/images/generations?api-version=preview"
def test_azure_v1_image_generation_json_body_sends_deployment_name():
"""The v1 route ignores the URL and routes by body ``model``, which must be the deployment name."""
url = "https://my-resource.openai.azure.com/openai/v1/images/generations?api-version=preview"
data = {"model": "gpt-image-2", "prompt": "x", "n": 1}
out = azure_deployment_image_generation_json_body(url, data, deployment_name="img-dep")
assert out["model"] == "img-dep"
assert out["prompt"] == "x"
assert data["model"] == "gpt-image-2"
assert azure_deployment_image_generation_json_body(url, data) == data
@pytest.mark.asyncio
async def test_azure_aimage_generation_v1_route_sends_deployment_name_in_body(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache())
azure_chat_completion = AzureChatCompletion()
model = "img-dep"
base_model = "gpt-image-2"
data = {"model": base_model, "prompt": "A beautiful image of a cat", "n": 1}
azure_client_params = {
"azure_endpoint": "https://my-resource.openai.azure.com",
"api_version": "preview",
}
route = respx_mock.post("https://my-resource.openai.azure.com/openai/v1/images/generations").mock(
return_value=httpx.Response(200, json={"created": 1234567890, "data": [{"b64_json": "aaaa"}]})
)
logging_obj = MagicMock()
logging_obj.pre_call = MagicMock()
logging_obj.post_call = MagicMock()
await azure_chat_completion.aimage_generation(
data=data,
model_response=None,
azure_client_params=azure_client_params,
api_key="test-api-key",
input=[],
logging_obj=logging_obj,
headers={},
model=model,
timeout=60.0,
)
request = route.calls.last.request
assert str(request.url) == ("https://my-resource.openai.azure.com/openai/v1/images/generations?api-version=preview")
sent_body = json.loads(request.content)
assert sent_body["model"] == model
assert sent_body["prompt"] == data["prompt"]
def test_azure_image_generation_v1_route_base_model_vs_deployment_name(respx_mock: respx.MockRouter):
"""On the v1 surface the body ``model`` must be the deployment name, never base_model."""
azure_chat_completion = AzureChatCompletion()
prompt = "A beautiful image of a cat"
model = "img-dep"
base_model = "gpt-image-2"
api_base = "https://my-resource.openai.azure.com"
api_version = "v1"
litellm_params = {
"base_model": base_model,
"api_base": api_base,
"api_version": api_version,
}
route = respx_mock.post(f"{api_base}/openai/v1/images/generations").mock(
return_value=httpx.Response(200, json={"created": 1234567890, "data": [{"b64_json": "aaaa"}]})
)
logging_obj = MagicMock()
logging_obj.pre_call = MagicMock()
logging_obj.post_call = MagicMock()
azure_chat_completion.image_generation(
prompt=prompt,
timeout=60.0,
optional_params={"n": 1, "size": "1024x1024"},
logging_obj=logging_obj,
headers={},
model=model,
api_key="test-api-key",
api_base=api_base,
api_version=api_version,
litellm_params=litellm_params,
)
request = route.calls.last.request
assert str(request.url) == f"{api_base}/openai/v1/images/generations?api-version={api_version}"
sent_body = json.loads(request.content)
assert sent_body["model"] == model
assert sent_body["prompt"] == prompt