mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-17 16:17:18 +00:00
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:
@@ -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 non–OpenAI-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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+81
-96
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user