mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
fix(image_edit): await an async transform hook and hide the URL policy verdict from callers
The image edit handler now awaits BaseImageEditConfig.async_transform_image_edit_request, and Black Forest Labs overrides it so URL images and masks download through async_safe_get instead of the blocking safe_get on the event loop. Rejected image fetches raise a fixed policy message with the user_url_allowed_hosts hint rather than echoing the resolver's verdict (resolved IP, DNS failure) back to the caller. The test fixture also fails any request-path call of the sync convert_url_to_base64 so a regression cannot pass unnoticed.
This commit is contained in:
parent
5c8016e99e
commit
0ee3bec046
8 changed files with 379 additions and 72 deletions
|
|
@ -78,6 +78,14 @@ def _process_image_response(response: Response, url: str) -> str:
|
|||
return result
|
||||
|
||||
|
||||
def _url_policy_rejection(url: str, verdict: SSRFError) -> "litellm.ImageFetchError":
|
||||
verbose_logger.warning("Image fetch of %s rejected by the URL policy: %s", url, verdict)
|
||||
return litellm.ImageFetchError(
|
||||
"Error: Unable to fetch image from URL. The proxy's URL policy rejected this host; "
|
||||
f"an admin can allow it with `user_url_allowed_hosts` in general_settings. url={url}"
|
||||
)
|
||||
|
||||
|
||||
async def async_convert_url_to_base64(url: str) -> str:
|
||||
if url.startswith("data:") and ";base64," in url:
|
||||
return url
|
||||
|
|
@ -100,7 +108,7 @@ async def async_convert_url_to_base64(url: str) -> str:
|
|||
except litellm.ImageFetchError:
|
||||
raise
|
||||
except SSRFError as e:
|
||||
raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL. {e} url={url}") from e
|
||||
raise _url_policy_rejection(url, e) from e
|
||||
except Exception:
|
||||
pass
|
||||
raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL after 3 attempts. url={url}")
|
||||
|
|
@ -128,7 +136,7 @@ def convert_url_to_base64(url: str) -> str:
|
|||
except litellm.ImageFetchError:
|
||||
raise
|
||||
except SSRFError as e:
|
||||
raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL. {e} url={url}") from e
|
||||
raise _url_policy_rejection(url, e) from e
|
||||
except Exception as e:
|
||||
verbose_logger.exception(e)
|
||||
raise litellm.ImageFetchError(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import types
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -102,6 +103,24 @@ class BaseImageEditConfig(ABC):
|
|||
) -> tuple[dict, RequestFiles]:
|
||||
pass
|
||||
|
||||
async def async_transform_image_edit_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str | None,
|
||||
image: FileTypes | None,
|
||||
image_edit_optional_request_params: Mapping[str, object],
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: Mapping[str, str],
|
||||
) -> tuple[dict, RequestFiles]:
|
||||
return self.transform_image_edit_request(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
image=image,
|
||||
image_edit_optional_request_params=dict(image_edit_optional_request_params),
|
||||
litellm_params=litellm_params,
|
||||
headers=dict(headers),
|
||||
)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ API Reference: https://docs.bfl.ai/
|
|||
|
||||
import base64
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -16,7 +17,7 @@ from httpx._types import RequestFiles
|
|||
|
||||
import litellm
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
|
||||
from litellm.litellm_core_utils.url_utils import safe_get
|
||||
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
|
|
@ -37,6 +38,22 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
_BFL_REQUEST_PARAMS: Final = (
|
||||
"seed",
|
||||
"output_format",
|
||||
"safety_tolerance",
|
||||
"prompt_upsampling",
|
||||
"aspect_ratio",
|
||||
"steps",
|
||||
"guidance",
|
||||
"grow_mask",
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
)
|
||||
|
||||
|
||||
class BlackForestLabsImageEditConfig(BaseImageEditConfig):
|
||||
"""
|
||||
Configuration for Black Forest Labs image editing.
|
||||
|
|
@ -85,34 +102,10 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig):
|
|||
BFL-specific params are passed through directly.
|
||||
"""
|
||||
optional_params: Final[dict[str, Any]] = {}
|
||||
|
||||
# Pass through BFL-specific params
|
||||
bfl_params: Final = [
|
||||
"seed",
|
||||
"output_format",
|
||||
"safety_tolerance",
|
||||
"prompt_upsampling",
|
||||
# Kontext-specific
|
||||
"aspect_ratio",
|
||||
# Fill/Inpaint-specific
|
||||
"steps",
|
||||
"guidance",
|
||||
"grow_mask",
|
||||
# Expand-specific
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
]
|
||||
|
||||
# Convert TypedDict to regular dict for access
|
||||
params_dict: Final = dict(image_edit_optional_params)
|
||||
|
||||
for param in bfl_params:
|
||||
if param in params_dict:
|
||||
value = params_dict[param]
|
||||
if value is not None:
|
||||
optional_params[param] = value
|
||||
params: Final[Mapping[str, object]] = image_edit_optional_params
|
||||
for param in _BFL_REQUEST_PARAMS:
|
||||
if (value := params.get(param)) is not None:
|
||||
optional_params[param] = value
|
||||
|
||||
# Set default output format
|
||||
if "output_format" not in optional_params:
|
||||
|
|
@ -251,23 +244,8 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig):
|
|||
"input_image": b64_image,
|
||||
}
|
||||
|
||||
# Add optional params (only BFL-recognized parameters)
|
||||
bfl_request_params: Final = [
|
||||
"seed",
|
||||
"output_format",
|
||||
"safety_tolerance",
|
||||
"prompt_upsampling",
|
||||
"aspect_ratio",
|
||||
"steps",
|
||||
"guidance",
|
||||
"grow_mask",
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
]
|
||||
for key, value in image_edit_optional_request_params.items():
|
||||
if key in bfl_request_params and value is not None:
|
||||
if key in _BFL_REQUEST_PARAMS and value is not None:
|
||||
request_body[key] = value
|
||||
|
||||
# Handle mask if provided (for inpainting)
|
||||
|
|
@ -277,7 +255,39 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig):
|
|||
request_body["mask"] = base64.b64encode(mask_bytes).decode("utf-8")
|
||||
|
||||
# BFL uses JSON, not multipart - return empty files
|
||||
return request_body, []
|
||||
return request_body, ()
|
||||
|
||||
async def async_transform_image_edit_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str | None,
|
||||
image: FileTypes | None,
|
||||
image_edit_optional_request_params: Mapping[str, object],
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: Mapping[str, str],
|
||||
) -> tuple[dict, RequestFiles]:
|
||||
downloaded_image: Final = await self._fetch_remote_image(image)
|
||||
downloaded_mask: Final = await self._fetch_remote_image(image_edit_optional_request_params.get("mask"))
|
||||
return self.transform_image_edit_request(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
image=image if downloaded_image is None else downloaded_image,
|
||||
image_edit_optional_request_params=(
|
||||
dict(image_edit_optional_request_params)
|
||||
if downloaded_mask is None
|
||||
else {**image_edit_optional_request_params, "mask": downloaded_mask}
|
||||
),
|
||||
litellm_params=litellm_params,
|
||||
headers=dict(headers),
|
||||
)
|
||||
|
||||
async def _fetch_remote_image(self, image: object) -> bytes | None:
|
||||
candidate: Final = image[0] if isinstance(image, list) and image else image
|
||||
if not isinstance(candidate, str) or not candidate.startswith(("http://", "https://")):
|
||||
return None
|
||||
response: Final = await async_safe_get(litellm.module_level_aclient, candidate, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def transform_image_edit_response(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -6787,7 +6787,7 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=dict(litellm_params),
|
||||
)
|
||||
|
||||
data, files = image_edit_provider_config.transform_image_edit_request(
|
||||
data, files = await image_edit_provider_config.async_transform_image_edit_request(
|
||||
model=model,
|
||||
image=image,
|
||||
prompt=prompt,
|
||||
|
|
|
|||
|
|
@ -607,7 +607,8 @@ ONE_PIXEL_PNG = base64.b64decode(
|
|||
|
||||
@pytest.fixture
|
||||
def async_only_image_fetch(monkeypatch):
|
||||
from litellm.litellm_core_utils.prompt_templates import image_handling
|
||||
from litellm.litellm_core_utils.prompt_templates import factory, image_handling
|
||||
from litellm.llms.gemini.chat import transformation as gemini_chat_transformation
|
||||
|
||||
fetch = SimpleNamespace(
|
||||
fetched=[],
|
||||
|
|
@ -627,6 +628,13 @@ def async_only_image_fetch(monkeypatch):
|
|||
request=httpx.Request("GET", url),
|
||||
)
|
||||
|
||||
def forbid_sync_convert(url, *args, **kwargs):
|
||||
if url.startswith(("http://", "https://")):
|
||||
raise litellm.ImageFetchError(f"sync convert_url_to_base64 ran on the request path: {url}")
|
||||
return url
|
||||
|
||||
monkeypatch.setattr(image_handling, "safe_get", forbid_sync_fetch)
|
||||
monkeypatch.setattr(image_handling, "async_safe_get", serve_png)
|
||||
for module in (image_handling, factory, gemini_chat_transformation):
|
||||
monkeypatch.setattr(module, "convert_url_to_base64", forbid_sync_convert)
|
||||
return fetch
|
||||
|
|
|
|||
|
|
@ -428,36 +428,61 @@ async def test_async_inline_remote_media_cancels_the_other_fetches_when_one_fail
|
|||
assert time.perf_counter() - started < 1
|
||||
|
||||
|
||||
async def test_async_convert_url_to_base64_reports_a_blocked_url_without_retrying(monkeypatch):
|
||||
_SSRF_VERDICTS = (
|
||||
"URL targets a blocked address (10.0.0.8). If this is a legitimate internal service, "
|
||||
"add the host to `user_url_allowed_hosts` in general_settings.",
|
||||
"DNS resolution failed for 'internal.example': [Errno 8] nodename nor servname provided, or not known",
|
||||
"No addresses found for 'internal.example'",
|
||||
)
|
||||
|
||||
|
||||
def _assert_one_verdict_free_message(messages, url):
|
||||
assert len(set(messages)) == 1
|
||||
assert "10.0.0.8" not in messages[0]
|
||||
assert "DNS" not in messages[0]
|
||||
assert "No addresses" not in messages[0]
|
||||
assert "user_url_allowed_hosts" in messages[0]
|
||||
assert url in messages[0]
|
||||
|
||||
|
||||
async def test_async_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeypatch):
|
||||
attempts = []
|
||||
|
||||
async def block(client, url, **kwargs):
|
||||
attempts.append(url)
|
||||
raise SSRFError("URL targets a blocked address (10.0.0.8)")
|
||||
|
||||
monkeypatch.setattr(image_handling, "async_safe_get", block)
|
||||
messages = []
|
||||
url = f"http://internal.example/{uuid.uuid4()}.png"
|
||||
|
||||
with pytest.raises(litellm.ImageFetchError, match=r"blocked address \(10\.0\.0\.8\)"):
|
||||
await async_convert_url_to_base64(url)
|
||||
for verdict in _SSRF_VERDICTS:
|
||||
|
||||
assert attempts == [url]
|
||||
async def block(client, fetched_url, verdict=verdict, **kwargs):
|
||||
attempts.append(fetched_url)
|
||||
raise SSRFError(verdict)
|
||||
|
||||
monkeypatch.setattr(image_handling, "async_safe_get", block)
|
||||
with pytest.raises(litellm.ImageFetchError) as raised:
|
||||
await async_convert_url_to_base64(url)
|
||||
messages.append(raised.value.message)
|
||||
|
||||
assert attempts == [url] * len(_SSRF_VERDICTS)
|
||||
_assert_one_verdict_free_message(messages, url)
|
||||
|
||||
|
||||
def test_convert_url_to_base64_reports_a_blocked_url_without_retrying(monkeypatch):
|
||||
def test_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeypatch):
|
||||
attempts = []
|
||||
|
||||
def block(client, url, **kwargs):
|
||||
attempts.append(url)
|
||||
raise SSRFError("URL targets a blocked address (10.0.0.8)")
|
||||
|
||||
monkeypatch.setattr(image_handling, "safe_get", block)
|
||||
messages = []
|
||||
url = f"http://internal.example/{uuid.uuid4()}.png"
|
||||
|
||||
with pytest.raises(litellm.ImageFetchError, match=r"blocked address \(10\.0\.0\.8\)"):
|
||||
convert_url_to_base64(url)
|
||||
for verdict in _SSRF_VERDICTS:
|
||||
|
||||
assert attempts == [url]
|
||||
def block(client, fetched_url, verdict=verdict, **kwargs):
|
||||
attempts.append(fetched_url)
|
||||
raise SSRFError(verdict)
|
||||
|
||||
monkeypatch.setattr(image_handling, "safe_get", block)
|
||||
with pytest.raises(litellm.ImageFetchError) as raised:
|
||||
convert_url_to_base64(url)
|
||||
messages.append(raised.value.message)
|
||||
|
||||
assert attempts == [url] * len(_SSRF_VERDICTS)
|
||||
_assert_one_verdict_free_message(messages, url)
|
||||
|
||||
|
||||
async def test_async_inline_remote_media_caps_in_flight_fetches_per_request(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import httpx
|
|||
import pytest
|
||||
|
||||
|
||||
from litellm.llms.black_forest_labs.image_edit import (
|
||||
transformation as bfl_transformation,
|
||||
)
|
||||
from litellm.llms.black_forest_labs.image_edit.transformation import (
|
||||
BlackForestLabsImageEditConfig,
|
||||
)
|
||||
|
|
@ -186,7 +189,7 @@ class TestBlackForestLabsImageEditTransformation:
|
|||
assert data["output_format"] == "jpeg"
|
||||
|
||||
# BFL uses JSON, not multipart - files should be empty
|
||||
assert files == []
|
||||
assert files == ()
|
||||
|
||||
def test_transform_image_edit_request_with_mask(self):
|
||||
"""Test request transformation with mask for inpainting."""
|
||||
|
|
@ -299,3 +302,76 @@ class TestBlackForestLabsImageEditTransformation:
|
|||
def test_use_multipart_form_data_returns_false(self):
|
||||
"""Test that use_multipart_form_data returns False for BFL."""
|
||||
assert self.config.use_multipart_form_data() is False
|
||||
|
||||
|
||||
async def test_async_transform_image_edit_request_downloads_url_images_with_the_async_fetcher(monkeypatch):
|
||||
served = b"png-bytes-from-cdn"
|
||||
fetched = []
|
||||
|
||||
def forbid_sync_fetch(client, url, **kwargs):
|
||||
raise AssertionError(f"sync image fetch ran on the event loop: {url}")
|
||||
|
||||
async def serve(client, url, **kwargs):
|
||||
fetched.append((url, kwargs.get("timeout")))
|
||||
return httpx.Response(200, content=served, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(bfl_transformation, "safe_get", forbid_sync_fetch)
|
||||
monkeypatch.setattr(bfl_transformation, "async_safe_get", serve)
|
||||
|
||||
data, files = await BlackForestLabsImageEditConfig().async_transform_image_edit_request(
|
||||
model="flux-kontext-pro",
|
||||
prompt="Add a red hat",
|
||||
image="https://cdn.example/photo.png",
|
||||
image_edit_optional_request_params={"mask": "https://cdn.example/mask.png", "seed": 7},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert base64.b64decode(data["input_image"]) == served
|
||||
assert base64.b64decode(data["mask"]) == served
|
||||
assert data["seed"] == 7
|
||||
assert files == ()
|
||||
assert fetched == [("https://cdn.example/photo.png", 60.0), ("https://cdn.example/mask.png", 60.0)]
|
||||
|
||||
|
||||
async def test_async_transform_image_edit_request_never_fetches_for_local_images(monkeypatch):
|
||||
def refuse(*args, **kwargs):
|
||||
raise AssertionError("no network fetch expected for local image bytes")
|
||||
|
||||
monkeypatch.setattr(bfl_transformation, "safe_get", refuse)
|
||||
monkeypatch.setattr(bfl_transformation, "async_safe_get", refuse)
|
||||
|
||||
data, _ = await BlackForestLabsImageEditConfig().async_transform_image_edit_request(
|
||||
model="flux-kontext-pro",
|
||||
prompt="Add a red hat",
|
||||
image=[BytesIO(b"first"), BytesIO(b"other")],
|
||||
image_edit_optional_request_params={"mask": b"mask-bytes"},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert base64.b64decode(data["input_image"]) == b"first"
|
||||
assert base64.b64decode(data["mask"]) == b"mask-bytes"
|
||||
|
||||
|
||||
async def test_async_transform_image_edit_request_downloads_only_the_first_url_of_a_list(monkeypatch):
|
||||
fetched = []
|
||||
|
||||
async def serve(client, url, **kwargs):
|
||||
fetched.append(url)
|
||||
return httpx.Response(200, content=b"first-bytes", request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(bfl_transformation, "safe_get", lambda *args, **kwargs: pytest.fail("sync fetch ran"))
|
||||
monkeypatch.setattr(bfl_transformation, "async_safe_get", serve)
|
||||
|
||||
data, _ = await BlackForestLabsImageEditConfig().async_transform_image_edit_request(
|
||||
model="flux-kontext-pro",
|
||||
prompt="Add a red hat",
|
||||
image=["https://cdn.example/a.png", "https://cdn.example/b.png"],
|
||||
image_edit_optional_request_params={},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert fetched == ["https://cdn.example/a.png"]
|
||||
assert base64.b64decode(data["input_image"]) == b"first-bytes"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.llms.base_llm.audio_transcription.transformation import (
|
|||
BaseAudioTranscriptionConfig,
|
||||
)
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.custom_httpx.llm_http_handler import (
|
||||
BaseLLMHTTPHandler,
|
||||
|
|
@ -31,7 +32,7 @@ from litellm.llms.azure.videos.transformation import AzureVideoConfig
|
|||
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import ModelResponse, TranscriptionResponse
|
||||
from litellm.types.utils import ImageObject, ImageResponse, ModelResponse, TranscriptionResponse
|
||||
|
||||
_ACTIVE_KEY = "_code_interpreter_interception_active"
|
||||
_SANDBOX_KEY = "_code_interpreter_interception_sandbox_key"
|
||||
|
|
@ -3244,6 +3245,11 @@ class _TransformRecordingConfig(BaseConfig):
|
|||
def get_error_class(self, error_message, status_code, headers):
|
||||
return BaseLLMException(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def get_model_response_iterator(self, streaming_response, sync_stream, json_mode=False):
|
||||
return litellm.OpenAIGPTConfig().get_model_response_iterator(
|
||||
streaming_response=streaming_response, sync_stream=sync_stream, json_mode=json_mode
|
||||
)
|
||||
|
||||
|
||||
def _start_async_completion(config, logging_obj=None):
|
||||
captured = {}
|
||||
|
|
@ -3312,3 +3318,158 @@ async def test_completion_keeps_sync_transform_request_before_returning_by_defau
|
|||
assert config.transform_calls == ["sync"]
|
||||
assert captured["body"] == {"transformed_by": "sync"}
|
||||
assert response.choices[0].message.content == "sync"
|
||||
|
||||
|
||||
def _sse_echoing_transformed_by(request):
|
||||
transformed_by = json.loads(request.content)["transformed_by"]
|
||||
chunk = {
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "stub-model",
|
||||
"choices": [{"index": 0, "delta": {"content": transformed_by}, "finish_reason": None}],
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode(),
|
||||
headers={"content-type": "text/event-stream"},
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
def _streaming_logging_obj():
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
logging_obj = Logging(
|
||||
model="stub-model",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="acompletion",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="async-transform-stream",
|
||||
function_id="f",
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
model="stub-model", user="", optional_params={}, litellm_params={}, custom_llm_provider="openai"
|
||||
)
|
||||
return logging_obj
|
||||
|
||||
|
||||
async def test_completion_streams_after_the_async_transform_request():
|
||||
config = _TransformRecordingConfig(transform_async=True)
|
||||
loop_thread = threading.current_thread()
|
||||
client = AsyncHTTPHandler()
|
||||
client.client = httpx.AsyncClient(transport=httpx.MockTransport(_sse_echoing_transformed_by))
|
||||
|
||||
stream = await BaseLLMHTTPHandler().completion(
|
||||
model="stub-model",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_base="https://llm.example/v1/chat",
|
||||
custom_llm_provider="openai",
|
||||
model_response=ModelResponse(),
|
||||
encoding=None,
|
||||
logging_obj=_streaming_logging_obj(),
|
||||
optional_params={},
|
||||
timeout=10.0,
|
||||
litellm_params={},
|
||||
acompletion=True,
|
||||
stream=True,
|
||||
client=client,
|
||||
provider_config=config,
|
||||
)
|
||||
collected = [chunk async for chunk in stream]
|
||||
|
||||
assert config.transform_calls == ["async"]
|
||||
assert config.sign_threads and all(thread is not loop_thread for thread in config.sign_threads)
|
||||
assert "".join(chunk.choices[0].delta.content or "" for chunk in collected) == "async"
|
||||
|
||||
|
||||
class _ImageEditRecordingConfig(BaseImageEditConfig):
|
||||
def __init__(self):
|
||||
self.transform_calls = []
|
||||
|
||||
def get_supported_openai_params(self, model):
|
||||
return []
|
||||
|
||||
def map_openai_params(self, image_edit_optional_params, model, drop_params):
|
||||
return dict(image_edit_optional_params)
|
||||
|
||||
def validate_environment(self, headers, model, api_key=None, litellm_params=None, api_base=None):
|
||||
return {}
|
||||
|
||||
def get_complete_url(self, model, api_base, litellm_params):
|
||||
return "https://images.example/v1/edits"
|
||||
|
||||
def use_multipart_form_data(self):
|
||||
return False
|
||||
|
||||
def transform_image_edit_request(
|
||||
self, model, prompt, image, image_edit_optional_request_params, litellm_params, headers
|
||||
):
|
||||
self.transform_calls.append("sync")
|
||||
return {"transformed_by": "sync"}, []
|
||||
|
||||
async def async_transform_image_edit_request(
|
||||
self, model, prompt, image, image_edit_optional_request_params, litellm_params, headers
|
||||
):
|
||||
self.transform_calls.append("async")
|
||||
return {"transformed_by": "async"}, []
|
||||
|
||||
def transform_image_edit_response(self, model, raw_response, logging_obj):
|
||||
return ImageResponse(data=[ImageObject(b64_json=raw_response.json()["transformed_by"])])
|
||||
|
||||
|
||||
def _echo_json_transport(captured):
|
||||
def handle(request):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json=captured["body"])
|
||||
|
||||
return httpx.MockTransport(handle)
|
||||
|
||||
|
||||
async def test_async_image_edit_handler_awaits_the_async_transform():
|
||||
config = _ImageEditRecordingConfig()
|
||||
captured = {}
|
||||
client = AsyncHTTPHandler()
|
||||
client.client = httpx.AsyncClient(transport=_echo_json_transport(captured))
|
||||
|
||||
response = await BaseLLMHTTPHandler().async_image_edit_handler(
|
||||
model="edit-model",
|
||||
image=b"raw-image",
|
||||
prompt="add a hat",
|
||||
image_edit_provider_config=config,
|
||||
image_edit_optional_request_params={},
|
||||
custom_llm_provider="openai",
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
logging_obj=Mock(),
|
||||
timeout=10.0,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert config.transform_calls == ["async"]
|
||||
assert captured["body"] == {"transformed_by": "async"}
|
||||
assert response.data[0].b64_json == "async"
|
||||
|
||||
|
||||
def test_image_edit_handler_keeps_the_sync_transform():
|
||||
config = _ImageEditRecordingConfig()
|
||||
captured = {}
|
||||
client = HTTPHandler()
|
||||
client.client = httpx.Client(transport=_echo_json_transport(captured))
|
||||
|
||||
response = BaseLLMHTTPHandler().image_edit_handler(
|
||||
model="edit-model",
|
||||
image=b"raw-image",
|
||||
prompt="add a hat",
|
||||
image_edit_provider_config=config,
|
||||
image_edit_optional_request_params={},
|
||||
custom_llm_provider="openai",
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
logging_obj=Mock(),
|
||||
timeout=10.0,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert config.transform_calls == ["sync"]
|
||||
assert captured["body"] == {"transformed_by": "sync"}
|
||||
assert response.data[0].b64_json == "sync"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue