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:
parent
c011a7e3ba
commit
766b67cf0d
@ -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 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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user