mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #38285 from BerriAI/litellm_azure_v1_image_routes
fix(azure): use /openai/v1 image routes for v1, preview and latest api versions
This commit is contained in:
commit
c453920f7a
6 changed files with 285 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
@ -1091,9 +1093,10 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
|
|||
AzureFoundryMAIImageGenerationConfig,
|
||||
)
|
||||
|
||||
api_base: str = azure_client_params.get("azure_endpoint", "") # "https://example-endpoint.openai.azure.com"
|
||||
if api_base.endswith("/"):
|
||||
api_base = api_base.rstrip("/")
|
||||
# deployment-scoped endpoints are moved to "base_url" by select_azure_base_url_or_endpoint
|
||||
api_base: str = (azure_client_params.get("azure_endpoint") or azure_client_params.get("base_url") or "").rstrip(
|
||||
"/"
|
||||
)
|
||||
api_version: Final[str] = azure_client_params.get("api_version", "")
|
||||
if model is None:
|
||||
model = ""
|
||||
|
|
@ -1113,6 +1116,14 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
|
|||
api_version=api_version,
|
||||
)
|
||||
|
||||
v1_url: Final = BaseAzureLLM.get_azure_v1_image_url(
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
route="/openai/images/generations",
|
||||
)
|
||||
if v1_url is not None:
|
||||
return v1_url
|
||||
|
||||
if "/openai/deployments/" in api_base:
|
||||
base_url_with_deployment = api_base
|
||||
else:
|
||||
|
|
@ -1167,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"))
|
||||
|
|
@ -1302,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):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import json
|
|||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from functools import lru_cache
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, Literal, NamedTuple, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -789,6 +790,32 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
|
||||
return str(final_url)
|
||||
|
||||
@staticmethod
|
||||
def get_azure_v1_image_url(api_base: str, api_version: str | None, route: str) -> str | None:
|
||||
"""
|
||||
Azure's v1 surface serves images at ``/openai/v1/images/{generations,edits}`` and routes by
|
||||
``model`` in the request body, so any deployment path and stale ``api-version`` in
|
||||
``api_base`` have to be dropped.
|
||||
|
||||
Returns None when ``api_version`` is a dated one, which still uses the deployment route.
|
||||
"""
|
||||
if not BaseAzureLLM._is_azure_v1_api_version(api_version):
|
||||
return None
|
||||
|
||||
base_url: Final = httpx.URL(api_base)
|
||||
openai_path_start: Final = base_url.path.find("/openai")
|
||||
resource_base: Final = str(
|
||||
base_url.copy_with(
|
||||
path=base_url.path if openai_path_start == -1 else base_url.path[:openai_path_start],
|
||||
params=httpx.QueryParams(tuple((k, v) for k, v in base_url.params.multi_items() if k != "api-version")),
|
||||
)
|
||||
)
|
||||
return BaseAzureLLM._get_base_azure_url(
|
||||
api_base=resource_base,
|
||||
litellm_params=MappingProxyType({"api_version": api_version}),
|
||||
route=route,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_azure_v1_api_version(api_version: str | None) -> bool:
|
||||
if api_version is None:
|
||||
|
|
|
|||
|
|
@ -93,8 +93,6 @@ class AzureImageEditConfig(OpenAIImageEditConfig):
|
|||
raise ValueError(
|
||||
f"api_base is required for Azure AI Studio. Please set the api_base parameter. Passed `api_base={api_base}`"
|
||||
)
|
||||
original_url: Final = httpx.URL(api_base)
|
||||
|
||||
# Resolve api_version: litellm_params > litellm.api_version > AZURE_API_VERSION env > default.
|
||||
# Mirrors the fallback chain used by the Azure chat path in common_utils.py,
|
||||
# so callers that set a global / env api_version don't get an unversioned URL.
|
||||
|
|
@ -105,6 +103,16 @@ class AzureImageEditConfig(OpenAIImageEditConfig):
|
|||
or litellm.AZURE_DEFAULT_API_VERSION
|
||||
)
|
||||
|
||||
v1_url: Final = BaseAzureLLM.get_azure_v1_image_url(
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
route="/openai/images/edits",
|
||||
)
|
||||
if v1_url is not None:
|
||||
return v1_url
|
||||
|
||||
original_url: Final = httpx.URL(api_base)
|
||||
|
||||
# Create a new dictionary with existing params
|
||||
query_params: Final = dict(original_url.params)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -233,3 +233,62 @@ def test_api_version_in_api_base_query_is_preserved(monkeypatch):
|
|||
)
|
||||
|
||||
assert _query_params(url) == {"api-version": "2024-05-01-preview"}
|
||||
|
||||
|
||||
def test_v1_api_version_uses_v1_route_and_keeps_model(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "api_version", None, raising=False)
|
||||
monkeypatch.delenv("AZURE_API_VERSION", raising=False)
|
||||
config = AzureImageEditConfig()
|
||||
|
||||
for api_version in ("v1", "preview", "latest"):
|
||||
url = config.get_complete_url(
|
||||
model=_FALLBACK_MODEL,
|
||||
api_base=_FALLBACK_API_BASE,
|
||||
litellm_params={"api_version": api_version},
|
||||
)
|
||||
assert urllib.parse.urlparse(url).path == "/openai/v1/images/edits"
|
||||
assert _query_params(url) == {"api-version": api_version}
|
||||
assert config.finalize_image_edit_request_data({"model": _FALLBACK_MODEL, "prompt": "x"}, url) == {
|
||||
"model": _FALLBACK_MODEL,
|
||||
"prompt": "x",
|
||||
}
|
||||
|
||||
|
||||
def test_v1_api_version_from_global_uses_v1_route(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "api_version", "preview", raising=False)
|
||||
monkeypatch.delenv("AZURE_API_VERSION", raising=False)
|
||||
|
||||
url = AzureImageEditConfig().get_complete_url(
|
||||
model=_FALLBACK_MODEL,
|
||||
api_base=_FALLBACK_API_BASE,
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert urllib.parse.urlparse(url).path == "/openai/v1/images/edits"
|
||||
|
||||
|
||||
def test_dated_api_version_still_uses_deployment_route(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "api_version", None, raising=False)
|
||||
monkeypatch.delenv("AZURE_API_VERSION", raising=False)
|
||||
|
||||
url = AzureImageEditConfig().get_complete_url(
|
||||
model=_FALLBACK_MODEL,
|
||||
api_base=_FALLBACK_API_BASE,
|
||||
litellm_params={"api_version": "2024-10-21"},
|
||||
)
|
||||
|
||||
assert urllib.parse.urlparse(url).path == f"/openai/deployments/{_FALLBACK_MODEL}/images/edits"
|
||||
|
||||
|
||||
def test_v1_api_version_replaces_deployment_scoped_api_base(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "api_version", None, raising=False)
|
||||
monkeypatch.delenv("AZURE_API_VERSION", raising=False)
|
||||
|
||||
url = AzureImageEditConfig().get_complete_url(
|
||||
model=_FALLBACK_MODEL,
|
||||
api_base=f"{_FALLBACK_API_BASE}/openai/deployments/{_FALLBACK_MODEL}/images/edits?api-version=2024-10-21",
|
||||
litellm_params={"api_version": "preview"},
|
||||
)
|
||||
|
||||
assert urllib.parse.urlparse(url).path == "/openai/v1/images/edits"
|
||||
assert _query_params(url) == {"api-version": "preview"}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -433,3 +436,154 @@ async def test_azure_aimage_generation_base_model_vs_deployment_name():
|
|||
wire_json = post_kwargs.get("json") or {}
|
||||
assert "model" not in wire_json
|
||||
assert data.get("model") == base_model
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_version", ["v1", "preview", "latest"])
|
||||
def test_azure_image_generation_v1_api_version_uses_v1_route(api_version):
|
||||
"""The v1 Azure surface exposes /openai/v1/images/generations and routes by body ``model``."""
|
||||
url = AzureChatCompletion().create_azure_base_url(
|
||||
azure_client_params={
|
||||
"azure_endpoint": "https://my-resource.openai.azure.com",
|
||||
"api_version": api_version,
|
||||
},
|
||||
model="gpt-image-1",
|
||||
base_model=None,
|
||||
)
|
||||
assert url == f"https://my-resource.openai.azure.com/openai/v1/images/generations?api-version={api_version}"
|
||||
data = {"model": "gpt-image-1", "prompt": "x"}
|
||||
assert azure_deployment_image_generation_json_body(url, data) == data
|
||||
|
||||
|
||||
def test_azure_image_generation_dated_api_version_uses_deployment_route():
|
||||
url = AzureChatCompletion().create_azure_base_url(
|
||||
azure_client_params={
|
||||
"azure_endpoint": "https://my-resource.openai.azure.com",
|
||||
"api_version": "2024-10-21",
|
||||
},
|
||||
model="gpt-image-1",
|
||||
base_model=None,
|
||||
)
|
||||
assert (
|
||||
url
|
||||
== "https://my-resource.openai.azure.com/openai/deployments/gpt-image-1/images/generations?api-version=2024-10-21"
|
||||
)
|
||||
assert "model" not in azure_deployment_image_generation_json_body(url, {"model": "gpt-image-1", "prompt": "x"})
|
||||
|
||||
|
||||
def test_azure_image_generation_v1_api_version_replaces_deployment_scoped_api_base():
|
||||
url = AzureChatCompletion().create_azure_base_url(
|
||||
azure_client_params={
|
||||
"azure_endpoint": "https://my-resource.openai.azure.com/openai/deployments/gpt-image-1/images/generations",
|
||||
"api_version": "preview",
|
||||
},
|
||||
model="gpt-image-1",
|
||||
base_model=None,
|
||||
)
|
||||
assert url == "https://my-resource.openai.azure.com/openai/v1/images/generations?api-version=preview"
|
||||
|
||||
|
||||
def test_azure_image_generation_v1_api_version_uses_base_url_client_param():
|
||||
url = AzureChatCompletion().create_azure_base_url(
|
||||
azure_client_params={
|
||||
"base_url": "https://my-resource.openai.azure.com/openai/deployments/gpt-image-1?api-version=2024-10-21",
|
||||
"api_version": "preview",
|
||||
},
|
||||
model="gpt-image-1",
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue