feat(decisions)!: send images to Cloudflare Clef through its images field (#45734)

* feat(decisions): send images to Cloudflare Clef

OpenAI-format input_image parts and the System One images field now reach Cloudflare clef and clef-flash in its images field, with remote URLs downloaded and inlined first. Providers that cannot take images drop them under drop_params and answer 400 otherwise.

* fix(decisions): follow drop_params for images and use the call timeout for image downloads

* fix(decisions): drop a malformed images entry on its own under drop_params

* fix(decisions): refuse images next to input whatever drop_params says

* fix(decisions): bound clef image fetches, honor drop_params for images, and log caller URLs

Refuse more than 4 images for Cloudflare clef before any download, keep the
caller's timeout as one deadline across image fetch retries, accept images
next to input under drop_params, validate image strings and objects, and pass
the caller's image URLs (not inlined bytes) to logging callbacks.

* fix(decisions): check input_image URLs and clef image sizes

* fix(decisions): accept data URL media type parameters and check clef image types

* fix(decisions): count clef images after dropping unsupported types and flag clef vision

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-14 12:14:48 +00:00 • committed by GitHub
parent 30b8db325e
commit 2f3ccbe7c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2968 additions and 144 deletions

View file

@ -1,5 +1,7 @@
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field, replace
from functools import partial
from types import MappingProxyType
from typing import Final, TypeAlias
import httpx
@ -9,19 +11,33 @@ from typing_extensions import assert_never
import litellm
from litellm.litellm_core_utils.core_helpers import RESPONSE_COST_HEADER, normalize_drop_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.prompt_templates.image_handling import (
async_convert_urls_to_base64,
convert_url_to_base64,
)
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.decisions.transformation import (
BaseDecisionsConfig,
image_media_type,
image_param,
input_images,
ir_to_systemone_response,
remote_image_urls,
systemone_request_to_ir,
with_images_where,
with_inlined_images,
)
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.llms.openai.decisions.transformation import ir_to_openai_response, openai_request_to_ir
from litellm.types.decisions import (
DecisionQuestion,
DecisionsAudio,
DecisionsBase64Image,
DecisionsImage,
DecisionsIRMessages,
DecisionsIRRequest,
DecisionsIRResponse,
DecisionsIRState,
DecisionsJSON,
DecisionsRequestBody,
DecisionsResponse,
@ -37,6 +53,7 @@ from litellm.utils import ProviderConfigManager
SystemOneQuestions: TypeAlias = Mapping[str, DecisionQuestion | Mapping[str, object]]
OpenAIDecisionQuestions: TypeAlias = Sequence[OpenAIDecisionQuestion | Mapping[str, object]]
DecisionsImages: TypeAlias = Sequence[str | DecisionsBase64Image | Mapping[str, object]]
DecisionsAudioClips: TypeAlias = Sequence[DecisionsAudio | Mapping[str, object]]
DecisionsVideoClips: TypeAlias = Sequence[DecisionsVideo | Mapping[str, object]]
DecisionsRequestFormat: TypeAlias = DecisionsRequestBody | OpenAIDecisionRequestBody
@ -46,19 +63,62 @@ _OPENAI_REQUEST_ADAPTER: Final[TypeAdapter[OpenAIDecisionRequestBody]] = TypeAda
_SAFETY_IDENTIFIER_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(
str | None, config=ConfigDict(title="safety_identifier")
)
_IMAGES_ADAPTER: Final[TypeAdapter[tuple[DecisionsImage, ...] | None]] = TypeAdapter(
tuple[DecisionsImage, ...] | None, config=ConfigDict(title="images")
)
_HANDLER: Final = BaseLLMHTTPHandler()
_BASE64_MARKER: Final = ";base64,"
_IMAGE_ADAPTER: Final[TypeAdapter[DecisionsImage]] = TypeAdapter(DecisionsImage, config=ConfigDict(title="images"))
_IMAGE_ENTRIES_ADAPTER: Final[TypeAdapter[tuple[object, ...]]] = TypeAdapter(tuple[object, ...])
def _valid_image(image: object) -> DecisionsImage | None:
try:
return _IMAGE_ADAPTER.validate_python(image)
except ValidationError:
return None
def _image_entries(images: object) -> tuple[object, ...]:
try:
return _IMAGE_ENTRIES_ADAPTER.validate_python(images)
except ValidationError:
return ()
def _is_well_formed_image(image: DecisionsImage) -> bool:
return _valid_image(image) is not None
def _no_image(_image: DecisionsImage) -> bool:
return False
def _request_images(images: object, drops_params: bool) -> tuple[DecisionsImage, ...]:
if not drops_params:
return _IMAGES_ADAPTER.validate_python(images) or ()
return tuple(image for image in map(_valid_image, _image_entries(images)) if image is not None)
@dataclass(frozen=True, slots=True)
class SystemOneRequestFields:
state: DecisionsJSON | None
questions: SystemOneQuestions | None
images: object = None
audio: DecisionsAudioClips | None = None
videos: DecisionsVideoClips | None = None
def validate(self, safety_identifier: str | None) -> DecisionsRequestBody:
def validate(self, *, safety_identifier: str | None, drops_params: bool) -> DecisionsRequestBody:
return _SYSTEMONE_REQUEST_ADAPTER.validate_python(
{"state": self.state, "questions": self.questions, "audio": self.audio, "videos": self.videos}
{
"state": self.state,
"questions": self.questions,
"images": _request_images(self.images, drops_params) or None,
"audio": self.audio,
"videos": self.videos,
}
)
@ -69,7 +129,7 @@ class OpenAIDecisionRequestFields:
audio: DecisionsAudioClips | None = None
videos: DecisionsVideoClips | None = None
def validate(self, safety_identifier: str | None) -> OpenAIDecisionRequestBody:
def validate(self, *, safety_identifier: str | None, drops_params: bool) -> OpenAIDecisionRequestBody:
return _OPENAI_REQUEST_ADAPTER.validate_python(
{
"input": self.input,
@ -92,7 +152,6 @@ class DecisionsCall:
provider_config: BaseDecisionsConfig
request: DecisionsRequestFormat
ir_request: DecisionsIRRequest
body: Mapping[str, object] = field(repr=False)
api_base: str
api_key: str | None = field(repr=False)
logging_obj: LiteLLMLoggingObj | None
@ -169,18 +228,61 @@ class _UnsupportedParams:
names: tuple[str, ...]
def _unsupported_param_names(
@dataclass(frozen=True, slots=True)
class _NoInputLeftAfterDrop:
names: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class _MalformedImages:
count: int
def _unsupported_params(
ir_request: DecisionsIRRequest, provider_config: BaseDecisionsConfig, model: str, custom_llm_provider: str
) -> tuple[str, ...]:
checks: Final = (
(
"safety_identifier",
ir_request.safety_identifier is not None and not provider_config.supports_safety_identifier,
),
("audio", bool(ir_request.audio) and not provider_config.supports_audio_input(model, custom_llm_provider)),
("videos", bool(ir_request.videos) and not provider_config.supports_video_input(model, custom_llm_provider)),
refuses_safety_identifier: Final = (
ir_request.safety_identifier is not None and not provider_config.supports_safety_identifier
)
return tuple(name for name, unsupported in checks if unsupported)
refused_image_param: Final = None if provider_config.supports_images else image_param(ir_request.input)
refuses_audio: Final = bool(ir_request.audio) and not provider_config.supports_audio_input(
model, custom_llm_provider
)
refuses_videos: Final = bool(ir_request.videos) and not provider_config.supports_video_input(
model, custom_llm_provider
)
return (
*(("safety_identifier",) if refuses_safety_identifier else ()),
*(() if refused_image_param is None else (refused_image_param,)),
*(("audio",) if refuses_audio else ()),
*(("videos",) if refuses_videos else ()),
)
def _without_unsupported_params(
ir_request: DecisionsIRRequest, provider_config: BaseDecisionsConfig
) -> DecisionsIRRequest:
return replace(
ir_request,
input=ir_request.input if provider_config.supports_images else with_images_where(ir_request.input, _no_image),
safety_identifier=ir_request.safety_identifier if provider_config.supports_safety_identifier else None,
)
def _leaves_no_input(decision_input: DecisionsIRState | DecisionsIRMessages) -> bool:
return isinstance(decision_input, DecisionsIRMessages) and not decision_input.messages
def _without_malformed_images(
ir_request: DecisionsIRRequest, drops_params: bool
) -> DecisionsIRRequest | _MalformedImages:
malformed: Final = sum(1 for image in input_images(ir_request.input) if not _is_well_formed_image(image))
if not malformed:
return ir_request
well_formed: Final = replace(ir_request, input=with_images_where(ir_request.input, _is_well_formed_image))
if not drops_params or _leaves_no_input(well_formed.input):
return _MalformedImages(count=malformed)
return well_formed
def _provider_ir_request(
@ -190,12 +292,20 @@ def _provider_ir_request(
provider_config: BaseDecisionsConfig,
model: str,
custom_llm_provider: str,
) -> DecisionsIRRequest | _UnsupportedParams:
ir_request: Final = _ir_request(request, safety_identifier)
unsupported: Final = _unsupported_param_names(ir_request, provider_config, model, custom_llm_provider)
if unsupported:
return _UnsupportedParams(unsupported)
return ir_request
kwargs: Mapping[str, object],
) -> DecisionsIRRequest | _UnsupportedParams | _NoInputLeftAfterDrop | _MalformedImages:
ir_request: Final = _without_malformed_images(_ir_request(request, safety_identifier), _drops_params(kwargs))
if isinstance(ir_request, _MalformedImages):
return ir_request
unsupported: Final = _unsupported_params(ir_request, provider_config, model, custom_llm_provider)
if not unsupported:
return ir_request
if not _drops_params(kwargs):
return _UnsupportedParams(names=unsupported)
supported: Final = _without_unsupported_params(ir_request, provider_config)
if _leaves_no_input(supported.input):
return _NoInputLeftAfterDrop(names=unsupported)
return supported
def prepare_call(
@ -231,7 +341,7 @@ def prepare_call(
request_safety_identifier: Final = _request_safety_identifier(safety_identifier, provider_config, kwargs)
request: Final = _fields_without_unsupported_media(
fields, provider_config, canonical_model, provider, kwargs
).validate(request_safety_identifier)
).validate(safety_identifier=request_safety_identifier, drops_params=_drops_params(kwargs))
except ValidationError as error:
raise litellm.BadRequestError(
message=f"Invalid Decisions request: {error}",
@ -260,6 +370,7 @@ def prepare_call(
provider_config=provider_config,
model=canonical_model,
custom_llm_provider=provider,
kwargs=kwargs,
)
if isinstance(ir_request, _UnsupportedParams):
raise litellm.UnsupportedParamsError(
@ -271,16 +382,24 @@ def prepare_call(
model=model,
llm_provider=provider,
)
body: Final = provider_config.transform_decisions_request(
model=canonical_model, request=ir_request, custom_llm_provider=provider
)
if isinstance(body, UnsupportedDecisionsRequest):
if isinstance(ir_request, _MalformedImages):
raise litellm.BadRequestError(
message=f"Decisions provider '{provider}' cannot serve this request: {body.reason}",
message=(
f"Invalid Decisions request: {ir_request.count} input_image part(s) have an image_url that is "
"neither a data:image/...;base64, URL nor an http(s) URL"
),
model=model,
llm_provider=provider,
)
if isinstance(ir_request, _NoInputLeftAfterDrop):
raise litellm.BadRequestError(
message=(
f"{provider} does not support parameters: {list(ir_request.names)}, for model={model}, "
"and dropping them leaves no input to decide on"
),
model=model,
llm_provider=provider,
)
logging_obj: Final = kwargs.get("litellm_logging_obj")
if isinstance(logging_obj, LiteLLMLoggingObj):
logging_obj.update_from_kwargs(
@ -299,7 +418,6 @@ def prepare_call(
provider_config=provider_config,
request=request,
ir_request=ir_request,
body=body,
api_base=resolved_api_base,
api_key=resolved_api_key,
logging_obj=logging_obj if isinstance(logging_obj, LiteLLMLoggingObj) else None,
@ -309,6 +427,122 @@ def prepare_call(
)
def _remote_image_urls(call: DecisionsCall) -> tuple[str, ...]:
if not call.provider_config.inlines_remote_images:
return ()
return remote_image_urls(call.ir_request.input)
def _with_data_urls(call: DecisionsCall, data_urls: Mapping[str, str]) -> DecisionsCall:
return replace(
call, ir_request=replace(call.ir_request, input=with_inlined_images(call.ir_request.input, data_urls))
)
def _with_remote_images_inlined(call: DecisionsCall) -> DecisionsCall:
remote_urls: Final = _remote_image_urls(call)
if not remote_urls:
return call
return _with_data_urls(
call, MappingProxyType({url: convert_url_to_base64(url, timeout=call.timeout) for url in remote_urls})
)
async def _async_with_remote_images_inlined(call: DecisionsCall) -> DecisionsCall:
remote_urls: Final = _remote_image_urls(call)
if not remote_urls:
return call
data_urls: Final = await async_convert_urls_to_base64(remote_urls, timeout=call.timeout)
return _with_data_urls(call, MappingProxyType(dict(zip(remote_urls, data_urls, strict=True))))
def _has_media_type_in(media_types: frozenset[str], image: DecisionsImage) -> bool:
media_type: Final = image_media_type(image)
return media_type is None or media_type in media_types
def _checked_image_types(call: DecisionsCall) -> DecisionsCall:
media_types: Final = call.provider_config.image_media_types
if media_types is None:
return call
found: Final = (image_media_type(image) for image in input_images(call.ir_request.input))
unsupported: Final = tuple(
dict.fromkeys(type_ for type_ in found if type_ is not None and type_ not in media_types)
)
if not unsupported:
return call
supported: Final = with_images_where(call.ir_request.input, partial(_has_media_type_in, media_types))
if _drops_params(call.litellm_params) and not _leaves_no_input(supported):
return replace(call, ir_request=replace(call.ir_request, input=supported))
raise litellm.BadRequestError(
message=(
f"{call.custom_llm_provider} takes only {', '.join(sorted(media_types))} images, "
f"got {', '.join(unsupported)}"
),
model=call.requested_model,
llm_provider=call.custom_llm_provider,
)
def _checked_image_count(call: DecisionsCall) -> DecisionsCall:
max_images: Final = call.provider_config.max_images
image_count: Final = len(input_images(call.ir_request.input))
if max_images is None or image_count <= max_images:
return call
raise litellm.BadRequestError(
message=f"{call.custom_llm_provider} takes at most {max_images} images per request, got {image_count}",
model=call.requested_model,
llm_provider=call.custom_llm_provider,
)
def _decoded_image_bytes(image: DecisionsImage) -> int:
if isinstance(image, DecisionsBase64Image):
return len(image.base64.rstrip("=")) * 3 // 4
marker_at: Final = image.lower().find(_BASE64_MARKER)
return 0 if marker_at < 0 else len(image[marker_at + len(_BASE64_MARKER) :].rstrip("=")) * 3 // 4
def _byte_limit(limit: int) -> str:
return f"{limit:,} bytes ({limit / (1024 * 1024):g} MiB)"
def _image_size_error(call: DecisionsCall) -> str | None:
sizes: Final = tuple(_decoded_image_bytes(image) for image in input_images(call.ir_request.input))
largest: Final = max(sizes, default=0)
per_image: Final = call.provider_config.max_image_bytes
total: Final = call.provider_config.max_total_image_bytes
if per_image is not None and largest > per_image:
return f"takes images of at most {_byte_limit(per_image)} each, got one of {largest:,} bytes"
if total is not None and sum(sizes) > total:
return f"takes at most {_byte_limit(total)} of images per request, got {sum(sizes):,} bytes"
return None
def _checked_image_sizes(call: DecisionsCall) -> DecisionsCall:
error: Final = _image_size_error(call)
if error is None:
return call
raise litellm.BadRequestError(
message=f"{call.custom_llm_provider} {error}",
model=call.requested_model,
llm_provider=call.custom_llm_provider,
)
def _request_body(call: DecisionsCall) -> Mapping[str, object]:
body: Final = call.provider_config.transform_decisions_request(
model=call.model, request=call.ir_request, custom_llm_provider=call.custom_llm_provider
)
if isinstance(body, UnsupportedDecisionsRequest):
raise litellm.BadRequestError(
message=f"Decisions provider '{call.custom_llm_provider}' cannot serve this request: {body.reason}",
model=call.requested_model,
llm_provider=call.custom_llm_provider,
)
return body
def _format_response(response: DecisionsIRResponse, call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
formatted: Final = _formatted_response(response, call)
provider_cost: Final = call.provider_config.provider_reported_cost(response)
@ -351,43 +585,51 @@ def _map_upstream_exception(error: Exception, call: DecisionsCall) -> Exception:
async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
supported: Final = _checked_image_count(_checked_image_types(call))
inlined: Final = _checked_image_sizes(_checked_image_types(await _async_with_remote_images_inlined(supported)))
body: Final = _request_body(inlined)
try:
response: Final = await _HANDLER.adecisions(
model=call.model,
custom_llm_provider=call.custom_llm_provider,
logging_obj=call.logging_obj,
provider_config=call.provider_config,
request=call.ir_request,
body=call.body,
api_base=call.api_base,
api_key=call.api_key,
headers=call.headers,
timeout=call.timeout,
litellm_params=call.litellm_params,
model=inlined.model,
custom_llm_provider=inlined.custom_llm_provider,
logging_obj=inlined.logging_obj,
provider_config=inlined.provider_config,
request=inlined.ir_request,
body=body,
logged_body=body if inlined is supported else _request_body(supported),
api_base=inlined.api_base,
api_key=inlined.api_key,
headers=inlined.headers,
timeout=inlined.timeout,
litellm_params=inlined.litellm_params,
)
except Exception as error:
raise _map_upstream_exception(error, call) from error
return _format_response(response, call)
raise _map_upstream_exception(error, inlined) from error
return _format_response(response, inlined)
def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
supported: Final = _checked_image_count(_checked_image_types(call))
inlined: Final = _checked_image_sizes(_checked_image_types(_with_remote_images_inlined(supported)))
body: Final = _request_body(inlined)
try:
response: Final = _HANDLER.decisions(
model=call.model,
custom_llm_provider=call.custom_llm_provider,
logging_obj=call.logging_obj,
provider_config=call.provider_config,
request=call.ir_request,
body=call.body,
api_base=call.api_base,
api_key=call.api_key,
headers=call.headers,
timeout=call.timeout,
litellm_params=call.litellm_params,
model=inlined.model,
custom_llm_provider=inlined.custom_llm_provider,
logging_obj=inlined.logging_obj,
provider_config=inlined.provider_config,
request=inlined.ir_request,
body=body,
logged_body=body if inlined is supported else _request_body(supported),
api_base=inlined.api_base,
api_key=inlined.api_key,
headers=inlined.headers,
timeout=inlined.timeout,
litellm_params=inlined.litellm_params,
)
except Exception as error:
raise _map_upstream_exception(error, call) from error
return _format_response(response, call)
raise _map_upstream_exception(error, inlined) from error
return _format_response(response, inlined)
def reject_other_format(*, model: str, kwargs: Mapping[str, object], field_name: str, other_function: str) -> None:
@ -398,3 +640,16 @@ def reject_other_format(*, model: str, kwargs: Mapping[str, object], field_name:
model=model,
llm_provider="",
)
def reject_images_next_to_input(*, model: str, kwargs: Mapping[str, object]) -> None:
if not kwargs.get("images") or _drops_params(kwargs):
return
raise litellm.BadRequestError(
message=(
"images belongs to the System One format; with input (OpenAI format), "
"send images as input_image content parts"
),
model=model,
llm_provider="",
)

View file

@ -16,6 +16,7 @@ from litellm.decisions.call import (
SystemOneRequestFields,
asend,
prepare_call,
reject_images_next_to_input,
send,
)
from litellm.types.decisions import DecisionsJSON, DecisionsResponse, OpenAIDecisionInput, OpenAIDecisionResponse
@ -26,9 +27,11 @@ _SYSTEMONE_QUESTIONS_ADAPTER: Final[TypeAdapter[SystemOneQuestions | None]] = Ty
_OPENAI_QUESTIONS_ADAPTER: Final[TypeAdapter[OpenAIDecisionQuestions | None]] = TypeAdapter(
OpenAIDecisionQuestions | None
)
_SYSTEMONE_FIELDS: Final = frozenset({"state", "images"})
def _request_fields(
model: str,
decision_input: OpenAIDecisionInput | None,
questions: OpenAIDecisionQuestions | SystemOneQuestions | None,
audio: DecisionsAudioClips | None,
@ -36,6 +39,7 @@ def _request_fields(
kwargs: Mapping[str, object],
) -> RequestFields:
if "state" not in kwargs:
reject_images_next_to_input(model=model, kwargs=kwargs)
return OpenAIDecisionRequestFields(
input=decision_input,
questions=_OPENAI_QUESTIONS_ADAPTER.validate_python(questions),
@ -50,6 +54,7 @@ def _request_fields(
return SystemOneRequestFields(
state=_STATE_ADAPTER.validate_python(kwargs["state"]),
questions=_SYSTEMONE_QUESTIONS_ADAPTER.validate_python(questions),
images=kwargs.get("images"),
audio=audio,
videos=videos,
)
@ -72,14 +77,14 @@ def _prepare_decisions_call(
) -> DecisionsCall:
return prepare_call(
model=model,
fields=_request_fields(decision_input, questions, audio, videos, kwargs),
fields=_request_fields(model, decision_input, questions, audio, videos, kwargs),
safety_identifier=safety_identifier,
api_key=api_key,
api_base=api_base,
timeout=timeout,
custom_llm_provider=custom_llm_provider,
extra_headers=extra_headers,
kwargs={key: value for key, value in kwargs.items() if key != "state"},
kwargs={key: value for key, value in kwargs.items() if key not in _SYSTEMONE_FIELDS},
)

View file

@ -4,12 +4,14 @@ Helper functions to handle images passed in messages
import asyncio
import base64
import time
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from itertools import chain
from types import MappingProxyType
from typing import Final
import httpx
from httpx import Response
import litellm
@ -22,6 +24,7 @@ from litellm.types.llms.openai import AllMessageValues
MAX_IMGS_IN_MEMORY: Final = 10
MAX_CONCURRENT_REMOTE_MEDIA_FETCHES: Final = 20
_FETCH_ATTEMPTS: Final = 3
in_memory_cache: Final = InMemoryCache(max_size_in_memory=MAX_IMGS_IN_MEMORY)
@ -73,6 +76,13 @@ def _process_image_response(response: Response, url: str) -> str:
return result
def _attempt_timeouts(timeout: float | httpx.Timeout | None) -> Iterable[float | httpx.Timeout | None]:
if timeout is None or isinstance(timeout, httpx.Timeout):
return (timeout,) * _FETCH_ATTEMPTS
deadline: Final = time.monotonic() + timeout
return (remaining for _ in range(_FETCH_ATTEMPTS) if (remaining := deadline - time.monotonic()) > 0)
def _rejected_image_fetch(url: str, verdict: SSRFError) -> "litellm.ImageFetchError":
verbose_logger.warning("Image fetch of %s rejected before any request went out: %s", url, verdict)
return litellm.ImageFetchError(
@ -81,7 +91,7 @@ def _rejected_image_fetch(url: str, verdict: SSRFError) -> "litellm.ImageFetchEr
)
async def async_convert_url_to_base64(url: str) -> str:
async def async_convert_url_to_base64(url: str, timeout: float | httpx.Timeout | None = None) -> str:
if url.startswith("data:") and ";base64," in url:
return url
@ -96,9 +106,9 @@ async def async_convert_url_to_base64(url: str) -> str:
return cached_result
client: Final = litellm.module_level_aclient
for _ in range(3):
for attempt_timeout in _attempt_timeouts(timeout):
try:
response = await async_safe_get(client, url)
response = await async_safe_get(client, url, timeout=attempt_timeout)
return _process_image_response(response, url)
except litellm.ImageFetchError:
raise
@ -109,7 +119,7 @@ async def async_convert_url_to_base64(url: str) -> str:
raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL after 3 attempts. url={url}")
def convert_url_to_base64(url: str) -> str:
def convert_url_to_base64(url: str, timeout: float | httpx.Timeout | None = None) -> str:
if url.startswith("data:") and ";base64," in url:
return url
@ -124,9 +134,9 @@ def convert_url_to_base64(url: str) -> str:
return cached_result
client: Final = litellm.module_level_client
for _ in range(3):
for attempt_timeout in _attempt_timeouts(timeout):
try:
response = safe_get(client, url)
response = safe_get(client, url, timeout=attempt_timeout)
return _process_image_response(response, url)
except litellm.ImageFetchError:
raise
@ -286,14 +296,16 @@ def _inline_message(
return inlined_message # pyright: ignore[reportReturnType] # the same message with its remote parts inlined
async def _fetch_data_url(url: str, in_flight: asyncio.Semaphore) -> str:
async def _fetch_data_url(url: str, in_flight: asyncio.Semaphore, timeout: float | httpx.Timeout | None) -> str:
async with in_flight:
return await async_convert_url_to_base64(url)
return await async_convert_url_to_base64(url, timeout=timeout)
async def _fetch_data_urls(remote_urls: tuple[str, ...]) -> tuple[str, ...]:
async def async_convert_urls_to_base64(
remote_urls: tuple[str, ...], timeout: float | httpx.Timeout | None = None
) -> tuple[str, ...]:
in_flight: Final = asyncio.Semaphore(MAX_CONCURRENT_REMOTE_MEDIA_FETCHES)
fetches: Final = tuple(asyncio.create_task(_fetch_data_url(url, in_flight)) for url in remote_urls)
fetches: Final = tuple(asyncio.create_task(_fetch_data_url(url, in_flight, timeout)) for url in remote_urls)
try:
return tuple(await asyncio.gather(*fetches))
except BaseException:
@ -329,6 +341,6 @@ async def async_inline_remote_media(
remote_urls: Final = _remote_urls_to_inline(messages, should_inline)
if not remote_urls:
return messages
data_urls: Final = await _fetch_data_urls(remote_urls)
data_urls: Final = await async_convert_urls_to_base64(remote_urls)
inlined: Final = MappingProxyType(dict(zip(remote_urls, data_urls, strict=True)))
return [_inline_message(message, inlined, should_inline) for message in messages]

View file

@ -1,6 +1,8 @@
import json
from abc import ABC
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import replace
from itertools import chain
from types import MappingProxyType
from typing import Final
@ -16,6 +18,8 @@ from litellm.types.decisions import (
ChoiceQuestion,
DecisionAnswer,
DecisionQuestion,
DecisionsBase64Image,
DecisionsImage,
DecisionsIRAnswer,
DecisionsIRChoiceAnswer,
DecisionsIRChoiceOption,
@ -53,9 +57,8 @@ from litellm.types.utils import is_decisions_model_mode
_PAYLOAD_ADAPTER: Final[TypeAdapter[object]] = TypeAdapter(object)
_SYSTEMONE_RESPONSE_ADAPTER: Final[TypeAdapter[DecisionsResponse]] = TypeAdapter(DecisionsResponse)
_RESERVED_HEADERS: Final[frozenset[str]] = frozenset({"authorization", "content-type"})
_TEXT_ONLY: Final = UnsupportedDecisionsRequest(
reason="input_image content parts are not supported because System One providers accept text input only"
)
_REMOTE_URL_PREFIXES: Final = ("http://", "https://")
_DATA_URL_PREFIX: Final = "data:"
def decisions_text(value: DecisionsJSON) -> str:
@ -101,39 +104,153 @@ def _ir_question(name: str, question: DecisionQuestion) -> DecisionsIRQuestion:
def systemone_request_to_ir(request: DecisionsRequestBody) -> DecisionsIRRequest:
return DecisionsIRRequest(
input=DecisionsIRState(state=request.state),
input=DecisionsIRState(state=request.state, images=tuple(request.images or ())),
audio=request.audio,
videos=request.videos,
questions=tuple(_ir_question(name, question) for name, question in request.questions.items()),
)
def _has_image(message: OpenAIDecisionInputMessage) -> bool:
return not isinstance(message.content, str) and any(
isinstance(part, OpenAIDecisionInputImage) for part in message.content
def _image_urls(message: OpenAIDecisionInputMessage) -> tuple[str, ...]:
if isinstance(message.content, str):
return ()
return tuple(part.image_url for part in message.content if isinstance(part, OpenAIDecisionInputImage))
def input_images(decision_input: DecisionsIRState | DecisionsIRMessages) -> tuple[DecisionsImage, ...]:
match decision_input:
case DecisionsIRState():
return decision_input.images
case DecisionsIRMessages():
return tuple(chain.from_iterable(_image_urls(message) for message in decision_input.messages))
case _:
assert_never(decision_input)
def image_param(decision_input: DecisionsIRState | DecisionsIRMessages) -> str | None:
if not input_images(decision_input):
return None
match decision_input:
case DecisionsIRState():
return "images"
case DecisionsIRMessages():
return "input_image"
case _:
assert_never(decision_input)
def _kept_part(part: OpenAIDecisionInputText | OpenAIDecisionInputImage, keep: Callable[[str], bool]) -> bool:
return isinstance(part, OpenAIDecisionInputText) or keep(part.image_url)
def _message_with_images_where(
message: OpenAIDecisionInputMessage, keep: Callable[[str], bool]
) -> OpenAIDecisionInputMessage | None:
if isinstance(message.content, str):
return message
kept_parts: Final = tuple(part for part in message.content if _kept_part(part, keep))
return message.model_copy(update={"content": kept_parts}) if kept_parts else None
def with_images_where(
decision_input: DecisionsIRState | DecisionsIRMessages, keep: Callable[[DecisionsImage], bool]
) -> DecisionsIRState | DecisionsIRMessages:
match decision_input:
case DecisionsIRState():
return replace(decision_input, images=tuple(image for image in decision_input.images if keep(image)))
case DecisionsIRMessages():
return DecisionsIRMessages(
messages=tuple(
kept
for message in decision_input.messages
if (kept := _message_with_images_where(message, keep)) is not None
)
)
case _:
assert_never(decision_input)
def remote_image_urls(decision_input: DecisionsIRState | DecisionsIRMessages) -> tuple[str, ...]:
return tuple(
dict.fromkeys(
image
for image in input_images(decision_input)
if isinstance(image, str) and image.lower().startswith(_REMOTE_URL_PREFIXES)
)
)
def _message_text(message: OpenAIDecisionInputMessage) -> str:
def _inlined_image(image: DecisionsImage, data_urls: Mapping[str, str]) -> DecisionsImage:
return data_urls.get(image, image) if isinstance(image, str) else image
def _inlined_part(
part: OpenAIDecisionInputText | OpenAIDecisionInputImage, data_urls: Mapping[str, str]
) -> OpenAIDecisionInputText | OpenAIDecisionInputImage:
if isinstance(part, OpenAIDecisionInputImage) and part.image_url in data_urls:
return part.model_copy(update={"image_url": data_urls[part.image_url]})
return part
def _inlined_message(message: OpenAIDecisionInputMessage, data_urls: Mapping[str, str]) -> OpenAIDecisionInputMessage:
if isinstance(message.content, str):
return message.content
return "\n\n".join(part.text for part in message.content if isinstance(part, OpenAIDecisionInputText))
return message
return message.model_copy(update={"content": tuple(_inlined_part(part, data_urls) for part in message.content)})
def _systemone_state(
decision_input: DecisionsIRState | DecisionsIRMessages,
) -> DecisionsJSON | UnsupportedDecisionsRequest:
def with_inlined_images(
decision_input: DecisionsIRState | DecisionsIRMessages, data_urls: Mapping[str, str]
) -> DecisionsIRState | DecisionsIRMessages:
match decision_input:
case DecisionsIRState():
return replace(
decision_input, images=tuple(_inlined_image(image, data_urls) for image in decision_input.images)
)
case DecisionsIRMessages():
return DecisionsIRMessages(
messages=tuple(_inlined_message(message, data_urls) for message in decision_input.messages)
)
case _:
assert_never(decision_input)
def _message_texts(message: OpenAIDecisionInputMessage) -> tuple[str, ...]:
if isinstance(message.content, str):
return (message.content,)
return tuple(part.text for part in message.content if isinstance(part, OpenAIDecisionInputText))
def _systemone_state(decision_input: DecisionsIRState | DecisionsIRMessages) -> DecisionsJSON:
match decision_input:
case DecisionsIRState():
return decision_input.state
case DecisionsIRMessages():
if any(_has_image(message) for message in decision_input.messages):
return _TEXT_ONLY
return "\n\n".join(_message_text(message) for message in decision_input.messages)
return "\n\n".join(chain.from_iterable(_message_texts(message) for message in decision_input.messages))
case _:
assert_never(decision_input)
def _media_type(content_type: str) -> str:
return content_type.split(";", 1)[0].strip().lower()
def image_media_type(image: DecisionsImage) -> str | None:
if isinstance(image, DecisionsBase64Image):
return _media_type(image.content_type)
if not image.lower().startswith(_DATA_URL_PREFIX):
return None
return _media_type(image[len(_DATA_URL_PREFIX) :])
def _systemone_image(image: DecisionsImage) -> str | Mapping[str, object]:
if isinstance(image, DecisionsBase64Image):
return {"content_type": image_media_type(image), "base64": image.base64}
media_type: Final = image_media_type(image)
if media_type is None:
return image
return f"{_DATA_URL_PREFIX}{media_type};base64,{image.partition(',')[2]}"
def _optional(key: str, value: object) -> Mapping[str, object]:
return {} if value is None else {key: value}
@ -177,9 +294,8 @@ def ir_to_systemone_request(
if request.videos and not supports_video:
return UnsupportedDecisionsRequest(reason="video input is not supported by this Decisions model")
state: Final = _systemone_state(request.input)
if isinstance(state, UnsupportedDecisionsRequest):
return state
keyed_questions: Final = zip(systemone_keys(request.questions), request.questions, strict=True)
images: Final = input_images(request.input)
return {
"model": model,
"state": state,
@ -194,6 +310,7 @@ def ir_to_systemone_request(
else {}
),
"questions": {key: _systemone_question(question) for key, question in keyed_questions},
**({"images": [_systemone_image(image) for image in images]} if images else {}),
}
@ -328,6 +445,12 @@ class BaseDecisionsConfig(ABC):
api_base_env: tuple[str, ...] = ()
api_key_required: bool = True
supports_safety_identifier: bool = False
supports_images: bool = False
max_images: int | None = None
max_image_bytes: int | None = None
max_total_image_bytes: int | None = None
image_media_types: frozenset[str] | None = None
inlines_remote_images: bool = False
health_check_questions: Mapping[str, Mapping[str, object]] = MappingProxyType(
{"reachable": MappingProxyType({"type": "noul", "instructions": "Is the service reachable?"})}
)

View file

@ -15,6 +15,12 @@ _RESPONSE_MAPPING_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapte
class CloudflareDecisionsConfig(BaseDecisionsConfig):
api_key_env = ("CLOUDFLARE_API_KEY",)
api_base_env = ("CLOUDFLARE_API_BASE",)
supports_images = True
max_images = 4
max_image_bytes = 4 * 1024 * 1024
max_total_image_bytes = 8 * 1024 * 1024
image_media_types = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp"})
inlines_remote_images = True
def get_default_api_base(self) -> str | None:
account_id: Final = normalize_nonempty_secret_str(get_secret_str("CLOUDFLARE_ACCOUNT_ID"))

View file

@ -1489,6 +1489,7 @@ class BaseLLMHTTPHandler:
logging_obj: LiteLLMLoggingObj | None,
provider_config: BaseDecisionsConfig,
body: Mapping[str, object],
logged_body: Mapping[str, object],
api_base: str,
api_key: str | None,
headers: Mapping[str, str],
@ -1498,7 +1499,7 @@ class BaseLLMHTTPHandler:
data: Final = dict(body)
if logging_obj is not None:
logging_obj.pre_call(
input=data,
input=dict(logged_body),
api_key=api_key,
model=model,
additional_args={"api_base": url, "complete_input_dict": data, "headers": outbound_headers},
@ -1513,6 +1514,7 @@ class BaseLLMHTTPHandler:
provider_config: BaseDecisionsConfig,
request: DecisionsIRRequest,
body: Mapping[str, object],
logged_body: Mapping[str, object],
api_base: str,
api_key: str | None,
headers: Mapping[str, str],
@ -1525,6 +1527,7 @@ class BaseLLMHTTPHandler:
logging_obj=logging_obj,
provider_config=provider_config,
body=body,
logged_body=logged_body,
api_base=api_base,
api_key=api_key,
headers=headers,
@ -1561,6 +1564,7 @@ class BaseLLMHTTPHandler:
provider_config: BaseDecisionsConfig,
request: DecisionsIRRequest,
body: Mapping[str, object],
logged_body: Mapping[str, object],
api_base: str,
api_key: str | None,
headers: Mapping[str, str],
@ -1573,6 +1577,7 @@ class BaseLLMHTTPHandler:
logging_obj=logging_obj,
provider_config=provider_config,
body=body,
logged_body=logged_body,
api_base=api_base,
api_key=api_key,
headers=headers,

View file

@ -8,6 +8,7 @@ import litellm
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig, decisions_text
from litellm.secret_managers.main import get_secret_str
from litellm.types.decisions import (
DecisionsImage,
DecisionsIRAnswer,
DecisionsIRChoiceAnswer,
DecisionsIRChoiceOption,
@ -32,6 +33,9 @@ from litellm.types.decisions import (
OpenAIChoiceQuestion,
OpenAIDecisionAnswer,
OpenAIDecisionInput,
OpenAIDecisionInputImage,
OpenAIDecisionInputMessage,
OpenAIDecisionInputText,
OpenAIDecisionInputTokensDetails,
OpenAIDecisionOutputTokensDetails,
OpenAIDecisionQuestion,
@ -145,8 +149,23 @@ def _openai_question(question: DecisionsIRQuestion) -> Mapping[str, object]:
assert_never(question)
def _image_url(image: DecisionsImage) -> str:
return image if isinstance(image, str) else f"data:{image.content_type};base64,{image.base64}"
def _state_message(state: DecisionsIRState) -> OpenAIDecisionInputMessage:
return OpenAIDecisionInputMessage(
content=(
*(OpenAIDecisionInputImage(type="input_image", image_url=_image_url(image)) for image in state.images),
OpenAIDecisionInputText(type="input_text", text=decisions_text(state.state)),
)
)
def _openai_input(decision_input: DecisionsIRState | DecisionsIRMessages) -> str | Sequence[Mapping[str, object]]:
match decision_input:
case DecisionsIRState() if decision_input.images:
return [_state_message(decision_input).model_dump(mode="json", exclude_none=True)]
case DecisionsIRState():
return decisions_text(decision_input.state)
case DecisionsIRMessages():
@ -297,6 +316,7 @@ def ir_to_openai_response(
class OpenAIDecisionsConfig(BaseDecisionsConfig):
path = "/v1/decisions"
supports_safety_identifier = True
supports_images = True
def get_default_api_base(self) -> str | None:
return "https://api.openai.com"

View file

@ -16029,7 +16029,8 @@
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/models/clef-omni/",
"supports_audio_input": true,
"supports_video_input": true
"supports_video_input": true,
"supports_vision": true
},
"cloudflare/@cf/cloudflare/clef-omni": {
"input_cost_per_token": 1.5e-07,
@ -16039,7 +16040,8 @@
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/models/clef-omni/",
"supports_audio_input": true,
"supports_video_input": true
"supports_video_input": true,
"supports_vision": true
},
"cloudflare/clef": {
"input_cost_per_token": 2.4e-07,
@ -16047,7 +16049,8 @@
"max_input_tokens": 65536,
"mode": "evaluation",
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/"
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/",
"supports_vision": true
},
"cloudflare/clef-flash": {
"input_cost_per_token": 9e-08,
@ -16055,7 +16058,8 @@
"max_input_tokens": 65536,
"mode": "evaluation",
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/"
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/",
"supports_vision": true
},
"cloudflare/@cf/cloudflare/clef": {
"input_cost_per_token": 2.4e-07,
@ -16063,7 +16067,8 @@
"max_input_tokens": 65536,
"mode": "evaluation",
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/"
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/",
"supports_vision": true
},
"cloudflare/@cf/cloudflare/clef-flash": {
"input_cost_per_token": 9e-08,
@ -16071,7 +16076,8 @@
"max_input_tokens": 65536,
"mode": "evaluation",
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/"
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/",
"supports_vision": true
},
"cloudflare/@cf/meta/llama-2-7b-chat-fp16": {
"input_cost_per_token": 1.923e-06,

View file

@ -10600,6 +10600,49 @@
"title": "Audio",
"type": "array"
},
"images": {
"anyOf": [
{
"items": {
"anyOf": [
{
"pattern": "^(?i:data:image/[^;,\\s]+(?:;[^;,\\s]+)*;base64,.+|https?://\\S+)$",
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"base64": {
"description": "Base64-encoded image bytes",
"minLength": 1,
"title": "Base64",
"type": "string"
},
"content_type": {
"description": "An image/* media type, such as image/png",
"pattern": "^image/\\S+$",
"title": "Content Type",
"type": "string"
}
},
"required": [
"content_type",
"base64"
],
"title": "DecisionsBase64Image",
"type": "object"
}
]
},
"type": "array"
},
{
"type": "null"
}
],
"description": "Images as data URLs or content_type plus base64, for providers that look at images",
"title": "Images"
},
"model": {
"description": "A decision model from the proxy model_list",
"title": "Model",
@ -11448,6 +11491,49 @@
"title": "Audio",
"type": "array"
},
"images": {
"anyOf": [
{
"items": {
"anyOf": [
{
"pattern": "^(?i:data:image/[^;,\\s]+(?:;[^;,\\s]+)*;base64,.+|https?://\\S+)$",
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"base64": {
"description": "Base64-encoded image bytes",
"minLength": 1,
"title": "Base64",
"type": "string"
},
"content_type": {
"description": "An image/* media type, such as image/png",
"pattern": "^image/\\S+$",
"title": "Content Type",
"type": "string"
}
},
"required": [
"content_type",
"base64"
],
"title": "DecisionsBase64Image",
"type": "object"
}
]
},
"type": "array"
},
{
"type": "null"
}
],
"description": "Images as data URLs or content_type plus base64, for providers that look at images",
"title": "Images"
},
"model": {
"description": "A decision model from the proxy model_list",
"title": "Model",

View file

@ -92,7 +92,7 @@ async def _invalid_request(
)
_FIELDS_CHECKED_AFTER_ROUTING: Final = frozenset({"safety_identifier", "audio", "videos"})
_FIELDS_CHECKED_AFTER_ROUTING: Final = frozenset({"safety_identifier", "images", "audio", "videos"})
def _fields_checked_before_routing(data: Mapping[str, object]) -> Mapping[str, object]:

View file

@ -6,6 +6,7 @@ import httpx
from litellm.decisions.call import (
DecisionsAudioClips,
DecisionsCall,
DecisionsImages,
DecisionsVideoClips,
SystemOneQuestions,
SystemOneRequestFields,
@ -23,6 +24,7 @@ def _prepare_systemone_call(
model: str,
state: DecisionsJSON | None,
questions: SystemOneQuestions | None,
images: DecisionsImages | None,
safety_identifier: str | None,
audio: DecisionsAudioClips | None,
videos: DecisionsVideoClips | None,
@ -36,7 +38,7 @@ def _prepare_systemone_call(
reject_other_format(model=model, kwargs=kwargs, field_name="input", other_function="decisions")
return prepare_call(
model=model,
fields=SystemOneRequestFields(state=state, questions=questions, audio=audio, videos=videos),
fields=SystemOneRequestFields(state=state, questions=questions, images=images, audio=audio, videos=videos),
safety_identifier=safety_identifier,
api_key=api_key,
api_base=api_base,
@ -65,12 +67,14 @@ async def asystemone(
safety_identifier: str | None = None,
audio: DecisionsAudioClips | None = None,
videos: DecisionsVideoClips | None = None,
images: DecisionsImages | None = None,
**kwargs: object, # kwargs-ok: litellm_params forwarded by @client
) -> DecisionsResponse:
call: Final = _prepare_systemone_call(
model=model,
state=state,
questions=questions,
images=images,
safety_identifier=safety_identifier,
audio=audio,
videos=videos,
@ -97,12 +101,14 @@ def systemone(
safety_identifier: str | None = None,
audio: DecisionsAudioClips | None = None,
videos: DecisionsVideoClips | None = None,
images: DecisionsImages | None = None,
**kwargs: object, # kwargs-ok: litellm_params forwarded by @client
) -> DecisionsResponse:
call: Final = _prepare_systemone_call(
model=model,
state=state,
questions=questions,
images=images,
safety_identifier=safety_identifier,
audio=audio,
videos=videos,

View file

@ -67,6 +67,19 @@ DecisionQuestionMap: TypeAlias = Annotated[
]
class DecisionsBase64Image(LiteLLMPydanticObjectBase):
content_type: Annotated[str, Field(pattern=r"^image/\S+$", description="An image/* media type, such as image/png")]
base64: Annotated[str, Field(min_length=1, description="Base64-encoded image bytes")]
model_config = ConfigDict(extra="forbid", frozen=True)
DecisionsImage: TypeAlias = (
Annotated[str, Field(pattern=r"^(?i:data:image/[^;,\s]+(?:;[^;,\s]+)*;base64,.+|https?://\S+)$")]
| DecisionsBase64Image
)
class DecisionsBase64Audio(LiteLLMPydanticObjectBase):
content_type: Annotated[str, Field(pattern=r"^audio/\S+$", description="An audio/* media type, such as audio/wav")]
base64: Annotated[str, Field(min_length=1, description="Base64-encoded audio bytes")]
@ -118,6 +131,10 @@ class DecisionsRequestBody(DecisionsMediaBody):
DecisionQuestionMap,
Field(description="Named noul, choice and score questions. Each key becomes a key in the answers"),
]
images: Annotated[
Sequence[DecisionsImage] | None,
Field(description="Images as data URLs or content_type plus base64, for providers that look at images"),
] = None
model_config = ConfigDict(extra="allow", frozen=True)
@ -131,6 +148,7 @@ class DecisionsCallParams(TypedDict, total=False):
model: Required[ReadOnly[str]]
state: Required[ReadOnly[DecisionsJSON]]
questions: Required[ReadOnly[DecisionQuestionMap]]
images: ReadOnly[Sequence[DecisionsImage] | None]
audio: ReadOnly[Sequence[DecisionsAudio]]
videos: ReadOnly[Sequence[DecisionsVideo]]
api_key: ReadOnly[str | None]
@ -419,6 +437,7 @@ _NO_EXTRA: Final[Mapping[str, object]] = MappingProxyType({})
@dataclass(frozen=True, slots=True)
class DecisionsIRState:
state: DecisionsJSON
images: tuple[DecisionsImage, ...] = ()
@dataclass(frozen=True, slots=True)

View file

@ -16029,7 +16029,8 @@
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/models/clef-omni/",
"supports_audio_input": true,
"supports_video_input": true
"supports_video_input": true,
"supports_vision": true
},
"cloudflare/@cf/cloudflare/clef-omni": {
"input_cost_per_token": 1.5e-07,
@ -16039,7 +16040,8 @@
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/models/clef-omni/",
"supports_audio_input": true,
"supports_video_input": true
"supports_video_input": true,
"supports_vision": true
},
"cloudflare/clef": {
"input_cost_per_token": 2.4e-07,
@ -16047,7 +16049,8 @@
"max_input_tokens": 65536,
"mode": "evaluation",
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/"
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/",
"supports_vision": true
},
"cloudflare/clef-flash": {
"input_cost_per_token": 9e-08,
@ -16055,7 +16058,8 @@
"max_input_tokens": 65536,
"mode": "evaluation",
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/"
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/",
"supports_vision": true
},
"cloudflare/@cf/cloudflare/clef": {
"input_cost_per_token": 2.4e-07,
@ -16063,7 +16067,8 @@
"max_input_tokens": 65536,
"mode": "evaluation",
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/"
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/",
"supports_vision": true
},
"cloudflare/@cf/cloudflare/clef-flash": {
"input_cost_per_token": 9e-08,
@ -16071,7 +16076,8 @@
"max_input_tokens": 65536,
"mode": "evaluation",
"output_cost_per_token": 0.0,
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/"
"source": "https://developers.cloudflare.com/workers-ai/platform/pricing/",
"supports_vision": true
},
"cloudflare/@cf/meta/llama-2-7b-chat-fp16": {
"input_cost_per_token": 1.923e-06,

View file

@ -0,0 +1,312 @@
import asyncio
import base64
import json
import os
import re
import threading
import uuid
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from queue import SimpleQueue
from types import MappingProxyType
from typing import Final, Literal
from urllib.parse import unquote, urlsplit, urlunsplit
import httpx
import psutil
import pytest
import yaml
from integration._support.bedrock_runtime_peer import PNG_1X1
from integration._support.client import Gateway, eventually, list_value, object_value
from integration._support.database import read_rows
from integration._support.process import owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
Route = Literal["/v1/systemone", "/systemone", "/v1/decisions", "/decisions"]
_ROUTES: Final[tuple[Route, ...]] = ("/v1/systemone", "/systemone", "/v1/decisions", "/decisions")
_API_KEY: Final = "synthetic-cloudflare-key"
_INPUT_TOKENS: Final = 412
_PNG_DATA_URL: Final = f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode()}"
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})")
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
_SYSTEM_ONE_QUESTIONS: Final[dict[str, JsonValue]] = {"red": {"type": "noul", "instructions": "Is the screenshot red?"}}
_OPENAI_QUESTIONS: Final[list[JsonValue]] = [
{"type": "predicate", "name": "red", "instructions": "Is the screenshot red?"}
]
@dataclass(frozen=True, slots=True)
class _Call:
route: Route
marker: str
images: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class _Served:
call: _Call
status: int
text: str
def _state(marker: str) -> str:
return f"Is the checkout screenshot red? marker-{marker}"
def _probability(marker: str) -> float:
return round(int(marker[:6], 16) / 0x1000000, 7)
def _marker_of(request: Request) -> str:
found: Final = _MARKER.search(request.body.decode() or request.target)
assert found is not None, (request.target, request.body[:200])
return found.group(1)
def _clef_reply(request: Request) -> Reply:
reply: Final[dict[str, JsonValue]] = {
"result": {
"model": "clef",
"answers": {"red": {"type": "noul", "noul": _probability(_marker_of(request))}},
"usage": {"input_tokens": _INPUT_TOKENS, "output_tokens": 0},
},
"success": True,
}
return Reply(body=json.dumps(reply).encode())
def _is_image_fetch(request: Request) -> bool:
return request.method == "GET" and urlsplit(request.target).path == "/image.png"
def _body(call: _Call, model: str) -> dict[str, JsonValue]:
if call.route.endswith("systemone"):
return {
"model": model,
"state": _state(call.marker),
"questions": _SYSTEM_ONE_QUESTIONS,
"images": list(call.images),
"num_retries": 0,
}
parts: Final[list[JsonValue]] = [
{"type": "input_text", "text": _state(call.marker)},
*({"type": "input_image", "image_url": image} for image in call.images),
]
return {
"model": model,
"input": [{"role": "user", "content": parts}],
"questions": _OPENAI_QUESTIONS,
"num_retries": 0,
}
def _upstream_by_marker(received: Sequence[Request]) -> dict[str, dict[str, JsonValue]]:
posts: Final = tuple(request for request in received if request.method == "POST")
for request in posts:
assert unquote(request.target) == "/ai/run/@cf/cloudflare/clef", request.target
assert request.headers["authorization"] == f"Bearer {_API_KEY}", request.headers
by_marker: Final = {_marker_of(request): _JSON_OBJECT.validate_json(request.body) for request in posts}
assert len(by_marker) == len(posts), "a request reached clef twice"
return by_marker
def _answered_probability(route: Route, answers: JsonValue) -> JsonValue:
if route.endswith("systemone"):
return object_value(object_value(answers)["red"])["noul"]
(answer,) = list_value(answers)
return object_value(answer)["probability"]
def _assert_answered(served: _Served) -> None:
assert served.status == 200, served.text
answers: Final = _JSON_OBJECT.validate_json(served.text)["answers"]
assert _answered_probability(served.call.route, answers) == _probability(served.call.marker), served.text
def _assert_clef_got_inlined_images(call: _Call, body: dict[str, JsonValue]) -> None:
assert body == {
"model": "clef",
"state": _state(call.marker),
"questions": _SYSTEM_ONE_QUESTIONS,
"images": [_PNG_DATA_URL] * len(call.images),
}, (call, body)
async def _send(client: httpx.AsyncClient, key: str, model: str, call: _Call) -> _Served:
response: Final = await client.post(call.route, json=_body(call, model), headers={"Authorization": f"Bearer {key}"})
return _Served(call=call, status=response.status_code, text=response.text)
async def _burst(
gateway: Gateway, model: str, calls: Sequence[_Call], *, tolerate_transport_errors: bool = False
) -> tuple[_Served, ...]:
async with httpx.AsyncClient(base_url=str(gateway.client.base_url), timeout=90, trust_env=False) as client:
results: Final = await asyncio.gather(
*(_send(client, gateway.key, model, call) for call in calls), return_exceptions=tolerate_transport_errors
)
for result in results:
assert not isinstance(result, BaseException) or isinstance(result, httpx.TransportError), repr(result)
return tuple(result for result in results if isinstance(result, _Served))
def _pooled_database_url(url: str) -> str:
parts: Final = urlsplit(url)
query: Final = "&".join(part for part in (parts.query, "connection_limit=5") if part)
return urlunsplit(parts._replace(query=query))
def _config(wire: Wire, tmp_path: Path) -> tuple[Path, str]:
model: Final = f"clef-images-chaos-{uuid.uuid4().hex[:8]}"
config: Final = _JSON_OBJECT.validate_python(
yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
)
path: Final = tmp_path / "clef-images-chaos.yaml"
path.write_text(
yaml.safe_dump(
{
**config,
"model_list": [
{
"model_name": model,
"litellm_params": {"model": "cloudflare/clef", "api_base": wire.url, "api_key": _API_KEY},
}
],
"general_settings": {
**object_value(config["general_settings"]),
"user_url_allowed_hosts": ["127.0.0.1"],
},
}
)
)
return path, model
def _successful_spend_rows(model: str, expected: int) -> int:
rows: Final = eventually(
lambda: read_rows('SELECT request_id, status FROM "LiteLLM_SpendLogs" WHERE model_group=%s', (model,)),
lambda found: sum(row["status"] == "success" for row in found) >= expected,
seconds=70,
)
assert len({row["request_id"] for row in rows}) == len(rows), rows
return sum(row["status"] == "success" for row in rows)
def _open_upstream_connections(pid: int, upstream: str) -> int:
port: Final = urlsplit(upstream).port
return sum(
1
for connection in psutil.Process(pid).net_connections(kind="tcp")
if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port
)
def _mixed_calls(wire: Wire, count: int) -> tuple[_Call, ...]:
def images(index: int, marker: str) -> tuple[str, ...]:
remote: Final = f"{wire.url}/image.png?id=marker-{marker}"
return ((_PNG_DATA_URL,), (remote,), (remote, _PNG_DATA_URL, remote, _PNG_DATA_URL))[index % 3]
markers: Final = tuple(uuid.uuid4().hex for _ in range(count))
return tuple(
_Call(route=_ROUTES[index % len(_ROUTES)], marker=marker, images=images(index, marker))
for index, marker in enumerate(markers)
)
def _overflow_calls(wire: Wire, count: int) -> tuple[_Call, ...]:
markers: Final = tuple(uuid.uuid4().hex for _ in range(count))
return tuple(
_Call(
route=_ROUTES[index % len(_ROUTES)], marker=marker, images=(f"{wire.url}/image.png?id=marker-{marker}",) * 5
)
for index, marker in enumerate(markers)
)
def _image_host_and_clef(request: Request) -> Reply:
if _is_image_fetch(request):
return Reply(body=PNG_1X1, content_type="image/png")
return _clef_reply(request)
@pytest.mark.timeout(600)
async def test_a_mixed_image_burst_across_two_workers_reaches_clef_once_per_request(
gateway: Gateway, tmp_path: Path
) -> None:
with wire_server(_image_host_and_clef) as wire:
path, model = _config(wire, tmp_path)
calls: Final = _mixed_calls(wire, 24)
overflow: Final = _overflow_calls(wire, 8)
overrides: Final = {"DATABASE_URL": _pooled_database_url(os.environ["DATABASE_URL"])}
with owned_proxy_process(gateway, tmp_path, overrides, config=path, workers=2) as owned:
served: Final = await _burst(owned.gateway, model, (*calls, *overflow))
assert len(served) == 32
overflow_markers: Final = frozenset(call.marker for call in overflow)
for item in served:
if item.call.marker in overflow_markers:
assert item.status == 400, item.text
assert "at most 4 images per request, got 5" in item.text, item.text
else:
_assert_answered(item)
received: Final = wire.drain()
fetched: Final = sorted(_marker_of(request) for request in received if _is_image_fetch(request))
assert overflow_markers.isdisjoint(fetched), fetched
by_marker: Final = _upstream_by_marker(received)
assert sorted(by_marker) == sorted(call.marker for call in calls)
for call in calls:
_assert_clef_got_inlined_images(call, by_marker[call.marker])
assert _successful_spend_rows(model, 24) == 24
@pytest.mark.timeout(600)
async def test_worker_sigkill_while_images_are_fetched_leaves_the_sibling_serving(
gateway: Gateway, tmp_path: Path
) -> None:
release: Final = threading.Event()
held_fetches: Final[SimpleQueue[str]] = SimpleQueue()
def respond(request: Request) -> Reply:
if not _is_image_fetch(request):
return _clef_reply(request)
held_fetches.put(_marker_of(request))
assert release.wait(timeout=60), "The image fetches were never released"
return Reply(body=PNG_1X1, content_type="image/png")
with wire_server(respond) as wire:
path, model = _config(wire, tmp_path)
calls: Final = tuple(
_Call(route="/v1/systemone", marker=marker, images=(f"{wire.url}/image.png?id=marker-{marker}",))
for marker in (uuid.uuid4().hex for _ in range(16))
)
overrides: Final = {"DATABASE_URL": _pooled_database_url(os.environ["DATABASE_URL"])}
with owned_proxy_process(gateway, tmp_path, overrides, config=path, workers=2) as owned:
workers: Final = eventually(
lambda: tuple(int(pid) for pid in _STARTED_WORKER.findall(owned.log.read_text())),
lambda pids: len(pids) == 2,
seconds=30,
)
burst: Final = asyncio.create_task(_burst(owned.gateway, model, calls, tolerate_transport_errors=True))
await asyncio.to_thread(eventually, held_fetches.qsize, lambda size: size == 16, 60)
held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire.url) for pid in workers})
assert sum(held_by.values()) == 16, held_by
victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__)
with owned.replacing_worker(victim_pid) as kill:
kill.freeze()
await asyncio.to_thread(kill)
release.set()
served: Final = await burst
assert held_by[survivor_pid] >= 8, held_by
assert len(served) == held_by[survivor_pid], (held_by, len(served))
for item in served:
_assert_answered(item)
follow_up: Final = _Call(route="/v1/decisions", marker=uuid.uuid4().hex, images=(_PNG_DATA_URL,))
(answered,) = await _burst(owned.gateway, model, (follow_up,))
_assert_answered(answered)
by_marker: Final = _upstream_by_marker(wire.drain())
assert sorted(by_marker) == sorted((*(item.call.marker for item in served), follow_up.marker))
for item in served:
_assert_clef_got_inlined_images(item.call, by_marker[item.call.marker])
_assert_clef_got_inlined_images(follow_up, by_marker[follow_up.marker])

View file

@ -0,0 +1,877 @@
import base64
import json
import math
import os
import re
import threading
import time
import uuid
from collections.abc import Iterator, Sequence
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Final, Literal
from urllib.parse import unquote, urlsplit, urlunsplit
import httpx
import pytest
import yaml
from integration._support.bedrock_runtime_peer import PNG_1X1
from integration._support.client import Gateway, eventually, gateway_from_environment, list_value, object_value
from integration._support.database import read_rows
from integration._support.process import graceful_stop_seconds, owned_proxy
from integration._support.timing import assert_within_factor_of_control, timed
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
Route = Literal["/v1/systemone", "/systemone", "/v1/decisions", "/decisions"]
_ROUTES: Final[tuple[Route, ...]] = ("/v1/systemone", "/systemone", "/v1/decisions", "/decisions")
_API_KEY: Final = "synthetic-cloudflare-key"
_CLEF: Final = "cloudflare/clef"
_ALLOWLISTED_MODEL: Final = "clef-images-allowlisted"
_INPUT_TOKENS: Final = 412
_PNG_BASE64: Final = base64.b64encode(PNG_1X1).decode()
_PNG_DATA_URL: Final = f"data:image/png;base64,{_PNG_BASE64}"
_JPEG_DATA_URL: Final = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ=="
_PNG_OBJECT: Final[dict[str, JsonValue]] = {"content_type": "image/png", "base64": _PNG_BASE64}
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_JSON_ROWS: Final = TypeAdapter(list[dict[str, JsonValue]])
_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})")
_SYSTEM_ONE_QUESTIONS: Final[dict[str, JsonValue]] = {"red": {"type": "noul", "instructions": "Is the screenshot red?"}}
_OPENAI_QUESTIONS: Final[list[JsonValue]] = [
{"type": "predicate", "name": "red", "instructions": "Is the screenshot red?"}
]
_SPEND_QUERY: Final = (
'SELECT spend, status, call_type, model_group, prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" '
"WHERE request_id = %s"
)
_OWNED_TIMEOUT: Final = int(2 * graceful_stop_seconds() + 120)
_COLD_OPENAPI_SECONDS: Final = 120
_SLOW_IMAGE_RELEASE: Final = threading.Event()
_IMAGE_INPUT_RECORDER: Final = """
import json
import os
import httpx
from litellm.integrations.custom_logger import CustomLogger
class ImageInputRecorder(CustomLogger):
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
payload = {"call_id": kwargs.get("litellm_call_id"), "input": kwargs.get("input")}
async with httpx.AsyncClient(trust_env=False, timeout=10) as client:
await client.post(os.environ["IMAGE_INPUT_SINK"], content=json.dumps(payload, default=str))
instance = ImageInputRecorder()
"""
def _marker_of(request: Request) -> str:
found: Final = _MARKER.search(request.body.decode())
assert found is not None, request.body[:200]
return found.group(1)
def _state(marker: str) -> str:
return f"Is the checkout screenshot red? marker-{marker}"
def _probability(marker: str) -> float:
return round(int(marker[:6], 16) / 0x1000000, 7)
def _clef_name(request: Request) -> str:
return unquote(request.target).rsplit("/", 1)[-1]
def _clef_reply(request: Request) -> Reply:
reply: Final[dict[str, JsonValue]] = {
"result": {
"model": _clef_name(request),
"answers": {"red": {"type": "noul", "noul": _probability(_marker_of(request))}},
"usage": {"input_tokens": _INPUT_TOKENS, "output_tokens": 0},
},
"success": True,
"errors": [],
"messages": [],
}
return Reply(body=json.dumps(reply).encode())
def _speaks_system_one(route: Route) -> bool:
return route.endswith("systemone")
def _openai_input(text: str, images: Sequence[JsonValue]) -> list[JsonValue]:
text_parts: Final[tuple[JsonValue, ...]] = ({"type": "input_text", "text": text},) if text else ()
image_parts: Final = tuple({"type": "input_image", "image_url": image} for image in images)
return [{"role": "user", "content": [*text_parts, *image_parts]}]
def _body(
route: Route, model: str, marker: str, images: Sequence[JsonValue], **extra: JsonValue
) -> dict[str, JsonValue]:
if _speaks_system_one(route):
return {
"model": model,
"state": _state(marker),
"questions": _SYSTEM_ONE_QUESTIONS,
"images": list(images),
**extra,
}
return {"model": model, "input": _openai_input(_state(marker), images), "questions": _OPENAI_QUESTIONS, **extra}
def _text_only_body(route: Route, model: str, marker: str, **extra: JsonValue) -> dict[str, JsonValue]:
if _speaks_system_one(route):
return {"model": model, "state": _state(marker), "questions": _SYSTEM_ONE_QUESTIONS, **extra}
return {"model": model, "input": _state(marker), "questions": _OPENAI_QUESTIONS, **extra}
def _expected_response(route: Route, clef_name: str, marker: str) -> dict[str, JsonValue]:
if _speaks_system_one(route):
return {
"model": clef_name,
"answers": {"red": {"type": "noul", "noul": _probability(marker)}},
"usage": {"input_tokens": _INPUT_TOKENS, "output_tokens": 0},
}
return {
"model": clef_name,
"answers": [{"type": "predicate", "name": "red", "probability": _probability(marker)}],
"usage": {
"input_tokens": _INPUT_TOKENS,
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
"output_tokens": 0,
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": _INPUT_TOKENS,
},
}
def _expected_upstream_body(clef_name: str, state: str, images: Sequence[JsonValue] = ()) -> dict[str, JsonValue]:
return {
"model": clef_name,
"state": state,
"questions": _SYSTEM_ONE_QUESTIONS,
**({"images": list(images)} if images else {}),
}
def _upstream_body(request: Request, clef_name: str = "clef") -> dict[str, JsonValue]:
assert request.method == "POST", (request.method, request.target)
assert unquote(request.target) == f"/ai/run/@cf/cloudflare/{clef_name}", request.target
assert request.headers["authorization"] == f"Bearer {_API_KEY}", request.headers
return _JSON_OBJECT.validate_json(request.body)
def _decide(
gateway: Gateway, route: Route, model: str, marker: str, images: Sequence[JsonValue], **extra: JsonValue
) -> httpx.Response:
return gateway.request("POST", route, _body(route, model, marker, images, **extra))
def _number(value: JsonValue) -> float:
assert isinstance(value, (int, float)) and not isinstance(value, bool), value
return float(value)
def _expected_spend(cost_map_key: str) -> float:
cost_map: Final = _JSON_OBJECT.validate_json(Path("model_prices_and_context_window.json").read_bytes())
priced: Final = cost_map.get(cost_map_key)
assert priced is not None, f"{cost_map_key} has no cost map entry"
return _INPUT_TOKENS * _number(object_value(priced)["input_cost_per_token"])
def _spend_row(call_id: str) -> dict[str, JsonValue]:
rows: Final = eventually(lambda: read_rows(_SPEND_QUERY, (call_id,)), lambda found: len(found) == 1, seconds=70)
return rows[0]
def _call_type(route: Route) -> str:
return "asystemone" if _speaks_system_one(route) else "adecisions"
def _assert_billed(gateway: Gateway, response: httpx.Response, model: str, route: Route, cost_map_key: str) -> None:
expected: Final = _expected_spend(cost_map_key)
assert math.isclose(float(response.headers["x-litellm-response-cost"]), expected, rel_tol=1e-9), response.headers
call_id: Final = response.headers["x-litellm-call-id"]
row: Final = _spend_row(call_id)
assert (row["status"], row["call_type"], row["model_group"], row["prompt_tokens"], row["completion_tokens"]) == (
"success",
_call_type(route),
model,
_INPUT_TOKENS,
0,
), row
assert math.isclose(_number(row["spend"]), expected, rel_tol=1e-9), row
listed: Final = gateway.request("GET", "/spend/logs", params={"request_id": call_id})
assert listed.status_code == 200, listed.text
(reported,) = _JSON_ROWS.validate_json(listed.content)
assert (reported["request_id"], reported["model_group"], reported["prompt_tokens"]) == (
call_id,
model,
_INPUT_TOKENS,
), reported
assert math.isclose(_number(reported["spend"]), expected, rel_tol=1e-9), reported
def _images_for(route: Route) -> list[JsonValue]:
return [_PNG_DATA_URL, _PNG_OBJECT] if _speaks_system_one(route) else [_PNG_DATA_URL, _JPEG_DATA_URL]
@pytest.mark.parametrize("route", _ROUTES)
def test_images_reach_clef_in_its_images_field_on_every_route_and_are_billed(gateway: Gateway, route: Route) -> None:
marker: Final = uuid.uuid4().hex
images: Final = _images_for(route)
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
response: Final = _decide(gateway, route, model, marker, images)
assert response.status_code == 200, response.text
assert response.json() == _expected_response(route, "clef", marker)
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), images)
_assert_billed(gateway, response, model, route, "cloudflare/@cf/cloudflare/clef")
@pytest.mark.parametrize(
("spelling", "clef_name"),
(("cloudflare/clef-flash", "clef-flash"), ("cloudflare/@cf/cloudflare/clef-omni", "clef-omni")),
ids=("clef-flash", "clef-omni"),
)
def test_clef_flash_and_clef_omni_take_images_and_carry_their_own_price(
gateway: Gateway, spelling: str, clef_name: str
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=spelling, api_base=wire.url, api_key=_API_KEY)
response: Final = _decide(gateway, "/v1/systemone", model, marker, [_PNG_DATA_URL])
assert response.status_code == 200, response.text
assert response.json() == _expected_response("/v1/systemone", clef_name, marker)
(request,) = wire.drain()
assert _upstream_body(request, clef_name) == _expected_upstream_body(clef_name, _state(marker), [_PNG_DATA_URL])
_assert_billed(gateway, response, model, "/v1/systemone", spelling)
def test_an_image_only_message_reaches_clef_with_an_empty_state(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
reply: Final = json.dumps(
{
"result": {
"model": "clef",
"answers": {"red": {"type": "noul", "noul": _probability(marker)}},
"usage": {"input_tokens": _INPUT_TOKENS, "output_tokens": 0},
},
"success": True,
}
).encode()
with wire_server(lambda _: Reply(body=reply)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
response: Final = gateway.request(
"POST",
"/v1/decisions",
{"model": model, "input": _openai_input("", [_PNG_DATA_URL]), "questions": _OPENAI_QUESTIONS},
)
assert response.status_code == 200, response.text
assert response.json() == _expected_response("/v1/decisions", "clef", marker)
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", "", [_PNG_DATA_URL])
def _luna_reply(request: Request) -> Reply:
reply: Final[dict[str, JsonValue]] = {
"model": "gpt-6-luna",
"answers": [{"type": "predicate", "name": "red", "probability": _probability(_marker_of(request))}],
"usage": {
"input_tokens": _INPUT_TOKENS,
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
"output_tokens": 0,
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": _INPUT_TOKENS,
},
}
return Reply(body=json.dumps(reply).encode())
def test_an_openai_decision_model_gets_system_one_images_as_input_image_parts(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_luna_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model="openai/gpt-6-luna", api_base=wire.url, api_key=_API_KEY)
response: Final = _decide(gateway, "/v1/systemone", model, marker, [_PNG_DATA_URL, _PNG_OBJECT])
assert response.status_code == 200, response.text
assert object_value(object_value(response.json()["answers"])["red"])["noul"] == _probability(marker)
(request,) = wire.drain()
assert unquote(request.target) == "/v1/decisions", request.target
assert _JSON_OBJECT.validate_json(request.body) == {
"model": "gpt-6-luna",
"input": [
{
"role": "user",
"type": "message",
"content": [
{"type": "input_image", "image_url": _PNG_DATA_URL},
{"type": "input_image", "image_url": _PNG_DATA_URL},
{"type": "input_text", "text": _state(marker)},
],
}
],
"questions": _OPENAI_QUESTIONS,
}
@pytest.mark.parametrize("route", ("/v1/systemone", "/v1/decisions"))
def test_more_images_than_clef_takes_are_refused_before_any_download_or_upstream_call(
gateway: Gateway, route: Route
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
strict: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True)
remote: Final = [f"{wire.url}/image.png?n={index}&id=marker-{marker}" for index in range(5)]
for model in (strict, dropping):
refused = _decide(gateway, route, model, marker, remote)
assert refused.status_code == 400, refused.text
assert "at most 4 images per request, got 5" in refused.text, refused.text
assert wire.drain() == ()
four: Final = [_PNG_DATA_URL, _JPEG_DATA_URL, _PNG_DATA_URL, _JPEG_DATA_URL]
accepted: Final = _decide(gateway, route, strict, marker, four)
assert accepted.status_code == 200, accepted.text
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), four)
_MALFORMED: Final[tuple[tuple[str, JsonValue], ...]] = (
("bare_string", _PNG_DATA_URL),
("number", 5),
("object_not_list", _PNG_OBJECT),
("url_object", [{"url": _PNG_DATA_URL}]),
("chat_image_url", [{"type": "image_url", "image_url": {"url": _PNG_DATA_URL}}]),
("extra_key", [{**_PNG_OBJECT, "detail": "high"}]),
("missing_base64", [{"content_type": "image/png"}]),
("non_image_type", [{"content_type": "text/plain", "base64": "AA=="}]),
("empty_base64", [{"content_type": "image/png", "base64": ""}]),
("not_a_url", ["a" * 5000]),
("non_image_data_url", ["data:text/plain;base64,AA=="]),
)
@pytest.mark.parametrize(("label", "images"), _MALFORMED, ids=[label for label, _ in _MALFORMED])
def test_malformed_images_are_refused_unless_the_deployment_drops_params(
gateway: Gateway, label: str, images: JsonValue
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
strict: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True)
body: Final = {**_text_only_body("/v1/systemone", strict, marker), "images": images}
refused: Final = gateway.request("POST", "/v1/systemone", body)
assert refused.status_code == 400, (label, refused.text)
assert "Invalid Decisions request" in refused.text and "images" in refused.text, (label, refused.text)
assert wire.drain() == (), label
dropped: Final = gateway.request("POST", "/v1/systemone", {**body, "model": dropping})
assert dropped.status_code == 200, (label, dropped.text)
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker)), label
def test_a_malformed_image_under_drop_params_keeps_the_well_formed_ones(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True)
images: Final[list[JsonValue]] = [
{"content_type": "text/plain", "base64": "AA=="},
_PNG_DATA_URL,
{"url": _PNG_DATA_URL},
_PNG_OBJECT,
]
response: Final = _decide(gateway, "/v1/systemone", dropping, marker, images)
assert response.status_code == 200, response.text
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), [_PNG_DATA_URL, _PNG_OBJECT])
_MALFORMED_IMAGE_URLS: Final = (
("not_a_url", "not-an-image"),
("pdf_data_url", "data:application/pdf;base64,AA=="),
("ftp_url", "ftp://images.example/a.png"),
)
@pytest.mark.parametrize(
("label", "image_url"), _MALFORMED_IMAGE_URLS, ids=[label for label, _ in _MALFORMED_IMAGE_URLS]
)
def test_a_malformed_input_image_is_refused_unless_the_deployment_drops_params(
gateway: Gateway, label: str, image_url: str
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
strict: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True)
refused: Final = _decide(gateway, "/v1/decisions", strict, marker, [image_url, _PNG_DATA_URL])
assert refused.status_code == 400, (label, refused.text)
assert "Invalid Decisions request: 1 input_image" in refused.text, (label, refused.text)
assert wire.drain() == (), label
dropped: Final = _decide(gateway, "/v1/decisions", dropping, marker, [image_url, _PNG_DATA_URL])
assert dropped.status_code == 200, (label, dropped.text)
assert dropped.json() == _expected_response("/v1/decisions", "clef", marker), label
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), [_PNG_DATA_URL]), label
_GIF_BASE64: Final = "R0lGODlhAQABAAAAACw="
_PNG_DATA_URL_WITH_NAME: Final = f"data:IMAGE/PNG;name=screen.png;base64,{_PNG_BASE64}"
_WEBP_DATA_URL: Final = "data:image/webp;base64,UklGRhoAAABXRUJQ"
@pytest.mark.parametrize("route", ("/v1/systemone", "/v1/decisions"))
def test_clef_gets_png_jpeg_and_webp_images_without_media_type_parameters(gateway: Gateway, route: Route) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
images: Final = [_PNG_DATA_URL_WITH_NAME, _JPEG_DATA_URL, _WEBP_DATA_URL]
response: Final = _decide(gateway, route, model, marker, images)
assert response.status_code == 200, response.text
assert response.json() == _expected_response(route, "clef", marker)
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body(
"clef", _state(marker), [_PNG_DATA_URL, _JPEG_DATA_URL, _WEBP_DATA_URL]
)
def test_an_openai_decision_model_gets_a_data_url_with_media_type_parameters_unchanged(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_luna_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model="openai/gpt-6-luna", api_base=wire.url, api_key=_API_KEY)
response: Final = _decide(gateway, "/v1/decisions", model, marker, [_PNG_DATA_URL_WITH_NAME])
assert response.status_code == 200, response.text
(request,) = wire.drain()
sent: Final = list_value(
object_value(list_value(_JSON_OBJECT.validate_json(request.body)["input"])[0])["content"]
)
assert sent == [
{"type": "input_text", "text": _state(marker)},
{"type": "input_image", "image_url": _PNG_DATA_URL_WITH_NAME},
]
_REFUSED_TYPES: Final[tuple[tuple[str, Route, JsonValue], ...]] = (
("gif_data_url", "/v1/decisions", f"data:image/gif;base64,{_GIF_BASE64}"),
("gif_object", "/v1/systemone", {"content_type": "image/gif", "base64": _GIF_BASE64}),
)
@pytest.mark.parametrize(("label", "route", "image"), _REFUSED_TYPES, ids=[label for label, _, _ in _REFUSED_TYPES])
def test_an_image_type_clef_refuses_is_refused_unless_the_deployment_drops_params(
gateway: Gateway, label: str, route: Route, image: JsonValue
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
strict: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True)
refused: Final = _decide(gateway, route, strict, marker, [image, _PNG_DATA_URL])
assert refused.status_code == 400, (label, refused.text)
assert (
"cloudflare takes only image/jpeg, image/jpg, image/png, image/webp images, got image/gif" in refused.text
), (label, refused.text)
assert wire.drain() == (), label
dropped: Final = _decide(gateway, route, dropping, marker, [image, _PNG_DATA_URL])
assert dropped.status_code == 200, (label, dropped.text)
assert dropped.json() == _expected_response(route, "clef", marker), label
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), [_PNG_DATA_URL]), label
@pytest.mark.parametrize("route", ("/v1/systemone", "/v1/decisions"))
def test_an_image_clef_refuses_does_not_count_toward_its_limit_when_the_deployment_drops_params(
gateway: Gateway, route: Route
) -> None:
marker: Final = uuid.uuid4().hex
four: Final = [_PNG_DATA_URL, _JPEG_DATA_URL, _PNG_DATA_URL, _JPEG_DATA_URL]
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True)
accepted: Final = _decide(gateway, route, dropping, marker, [f"data:image/gif;base64,{_GIF_BASE64}", *four])
assert accepted.status_code == 200, accepted.text
assert accepted.json() == _expected_response(route, "clef", marker)
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), four)
_MIB: Final = 1024 * 1024
def _png_data_url(decoded_bytes: int) -> str:
return f"data:image/png;base64,{base64.b64encode(bytes(decoded_bytes)).decode()}"
def _png_object(decoded_bytes: int) -> dict[str, JsonValue]:
return {"content_type": "image/png", "base64": base64.b64encode(bytes(decoded_bytes)).decode()}
_OVERSIZED: Final[tuple[tuple[str, Route, tuple[JsonValue, ...], str], ...]] = (
("one_over_4mib", "/v1/systemone", (_png_object(4 * _MIB + 1),), "got one of 4,194,305 bytes"),
("one_over_4mib", "/v1/decisions", (_png_data_url(4 * _MIB + 1),), "got one of 4,194,305 bytes"),
(
"total_over_8mib",
"/v1/systemone",
(_png_data_url(4 * _MIB), _png_object(4 * _MIB), _png_data_url(1)),
"got 8,388,609 bytes",
),
)
@pytest.mark.parametrize(
("label", "route", "images", "refusal"), _OVERSIZED, ids=[f"{label}{route}" for label, route, _, _ in _OVERSIZED]
)
def test_images_over_clefs_size_limits_are_refused_before_the_upstream_call(
gateway: Gateway, label: str, route: Route, images: tuple[JsonValue, ...], refusal: str
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
strict: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True)
for model in (strict, dropping):
refused = _decide(gateway, route, model, marker, images)
assert refused.status_code == 400, (label, refused.text[:500])
assert refusal in refused.text, (label, refused.text[:500])
assert wire.drain() == (), label
at_limits: Final[list[JsonValue]] = [_png_data_url(4 * _MIB), _png_object(4 * _MIB)]
accepted: Final = _decide(gateway, "/v1/systemone", strict, marker, at_limits)
assert accepted.status_code == 200, accepted.text[:500]
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), at_limits), label
def _raw_system_one(gateway: Gateway, model: str, marker: str, images_fragment: str) -> httpx.Response:
raw: Final = (
f'{{"model": {json.dumps(model)}, "state": {json.dumps(_state(marker))}, '
f'"questions": {json.dumps(_SYSTEM_ONE_QUESTIONS)}, {images_fragment}}}'
)
return gateway.client.post(
"/v1/systemone",
content=raw.encode(),
headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"},
)
def test_a_duplicated_images_key_is_judged_by_its_last_value(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
malformed: Final = json.dumps([{"content_type": "text/plain", "base64": "AA=="}])
good: Final = json.dumps([_PNG_DATA_URL])
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
refused: Final = _raw_system_one(gateway, model, marker, f'"images": {good}, "images": {malformed}')
assert refused.status_code == 400, refused.text
assert "Invalid Decisions request" in refused.text, refused.text
assert wire.drain() == ()
accepted: Final = _raw_system_one(gateway, model, marker, f'"images": {malformed}, "images": {good}')
assert accepted.status_code == 200, accepted.text
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), [_PNG_DATA_URL])
def test_images_next_to_input_are_refused_unless_params_are_dropped(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
strict: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True)
refused: Final = gateway.request(
"POST", "/v1/decisions", _text_only_body("/v1/decisions", strict, marker, images=[_PNG_DATA_URL])
)
assert refused.status_code == 400, refused.text
assert "images belongs to the System One format" in refused.text, refused.text
assert wire.drain() == ()
for accepted in (
gateway.request(
"POST", "/v1/decisions", _text_only_body("/v1/decisions", dropping, marker, images=[_PNG_DATA_URL])
),
gateway.request(
"POST",
"/v1/decisions",
_text_only_body("/v1/decisions", strict, marker, images=[_PNG_DATA_URL], drop_params=True),
),
):
assert accepted.status_code == 200, accepted.text
assert accepted.json() == _expected_response("/v1/decisions", "clef", marker)
assert [_upstream_body(request) for request in wire.drain()] == [
_expected_upstream_body("clef", _state(marker))
] * 2
def test_a_remote_image_on_the_shared_proxy_is_refused_before_any_fetch(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
for route in ("/v1/systemone", "/v1/decisions"):
refused = _decide(gateway, route, model, marker, [f"{wire.url}/image.png?id=marker-{marker}"])
assert refused.status_code == 400, (route, refused.text)
assert "Unable to fetch image from URL" in refused.text, (route, refused.text)
assert "user_url_allowed_hosts" in refused.text, (route, refused.text)
assert wire.drain() == ()
def test_a_clef_rejection_of_an_image_reaches_the_caller_like_a_text_only_one(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
message: Final = "Image could not be decoded"
rejection: Final = json.dumps(
{"result": {}, "success": False, "errors": [{"code": 5006, "message": message}], "messages": []}
).encode()
garbage: Final = "data:image/png;base64," + "a" * 5000
with wire_server(lambda _: Reply(status=422, body=rejection)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
text_only: Final = gateway.request(
"POST", "/v1/systemone", _text_only_body("/v1/systemone", model, marker, num_retries=0)
)
with_image: Final = _decide(gateway, "/v1/systemone", model, marker, [garbage], num_retries=0)
for response in (text_only, with_image):
assert response.status_code >= 400, response.text
assert message in response.text and "answers" not in response.text, response.text
assert with_image.status_code == text_only.status_code, (text_only.status_code, with_image.status_code)
first, second = (_upstream_body(request) for request in wire.drain())
assert first == _expected_upstream_body("clef", _state(marker))
assert second == _expected_upstream_body("clef", _state(marker), [garbage])
def test_an_unauthenticated_image_request_is_refused_on_every_route(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
for route in _ROUTES:
response = gateway.client.post(route, json=_body(route, model, marker, [_PNG_DATA_URL]))
assert response.status_code == 401, (route, response.text)
assert "Authentication Error" in response.text, response.text
assert wire.drain() == ()
def test_unrelated_chat_keeps_working_after_a_run_of_refused_image_requests(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
control: Final = scenario.model()
for _, images in _MALFORMED:
refused = gateway.request(
"POST", "/v1/systemone", {**_text_only_body("/v1/systemone", model, marker), "images": images}
)
assert refused.status_code == 400, refused.text
assert _decide(gateway, "/v1/systemone", model, marker, [_PNG_DATA_URL] * 5).status_code == 400
assert wire.drain() == ()
assert gateway.chat(control)["choices"], "the control deployment stopped answering"
accepted: Final = _decide(gateway, "/v1/systemone", model, marker, [_PNG_DATA_URL])
assert accepted.status_code == 200, accepted.text
(request,) = wire.drain()
assert _upstream_body(request) == _expected_upstream_body("clef", _state(marker), [_PNG_DATA_URL])
def _timed_liveliness(gateway: Gateway) -> tuple[int, float]:
started: Final = time.monotonic()
response: Final = gateway.request("GET", "/health/liveliness")
return response.status_code, time.monotonic() - started
_WORST_CASE_IMAGES: Final = (
("unterminated_data_url", "DATA:IMAGE/" + "x" * 2_000_000),
("url_with_trailing_space", "HTTPS://" + "x" * 2_000_000 + " "),
("parameters_without_base64", "DATA:IMAGE/PNG" + ";x" * 1_000_000),
)
@pytest.mark.parametrize(("label", "image"), _WORST_CASE_IMAGES, ids=[label for label, _ in _WORST_CASE_IMAGES])
def test_the_worst_case_image_string_is_refused_in_time_while_health_keeps_answering(
gateway: Gateway, label: str, image: str
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_clef_reply) as wire, gateway.scenario() as scenario, ThreadPoolExecutor(max_workers=1) as pool:
model: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY)
control: Final = timed(lambda: _decide(gateway, "/v1/systemone", model, marker, ["x" * len(image)]))
assert control.value.status_code == 400, control.value.text
liveliness: Final = pool.submit(_timed_liveliness, gateway)
refused: Final = timed(lambda: _decide(gateway, "/v1/systemone", model, marker, [image]))
health_status, health_elapsed = liveliness.result()
assert refused.value.status_code == 400, (label, refused.value.text[:500])
assert "Invalid Decisions request" in refused.value.text, (label, refused.value.text[:500])
assert_within_factor_of_control(refused.seconds, control=control.seconds, factor=5)
assert health_status == 200, (health_status, health_elapsed)
assert_within_factor_of_control(health_elapsed, control=control.seconds, factor=5)
assert wire.drain() == ()
def _image_object_schema(schema: dict[str, JsonValue]) -> dict[str, JsonValue]:
images: Final = object_value(object_value(schema["properties"])["images"])
array: Final = next(
object_value(branch) for branch in list_value(images["anyOf"]) if object_value(branch).get("type") == "array"
)
entries: Final = list_value(object_value(array["items"])["anyOf"])
return next(object_value(entry) for entry in entries if object_value(entry).get("type") == "object")
def test_openapi_documents_what_an_image_object_must_carry(gateway: Gateway) -> None:
response: Final = gateway.client.get(
"/openapi.json", headers={"Authorization": f"Bearer {gateway.key}"}, timeout=_COLD_OPENAPI_SECONDS
)
assert response.status_code == 200, response.text
paths: Final = object_value(_JSON_OBJECT.validate_json(response.content)["paths"])
for route in ("/v1/systemone", "/systemone"):
post: Final = object_value(object_value(paths[route])["post"])
content: Final = object_value(object_value(post["requestBody"])["content"])
schema: Final = object_value(object_value(content["application/json"])["schema"])
properties: Final = object_value(_image_object_schema(schema)["properties"])
assert object_value(properties["content_type"])["pattern"] == r"^image/\S+$", (route, properties)
assert object_value(properties["base64"])["minLength"] == 1, (route, properties)
def _pooled_database_url(url: str) -> str:
parts: Final = urlsplit(url)
query: Final = "&".join(part for part in (parts.query, "connection_limit=5") if part)
return urlunsplit(parts._replace(query=query))
def _image_host_and_clef(request: Request) -> Reply:
path: Final = urlsplit(request.target).path
if request.method == "GET" and path == "/image.png":
return Reply(body=PNG_1X1, content_type="image/png")
if request.method == "GET" and path == "/page.html":
return Reply(body=b"<html></html>", content_type="text/html; charset=utf-8")
if request.method == "GET" and path == "/slow.png":
_SLOW_IMAGE_RELEASE.wait(timeout=30)
return Reply(body=PNG_1X1, content_type="image/png")
if request.method == "GET":
return Reply(status=404, body=b"not found", content_type="text/plain")
return _clef_reply(request)
@pytest.fixture(scope="module")
def rig() -> Iterator[Gateway]:
with gateway_from_environment() as gateway:
yield gateway
@pytest.fixture(scope="module")
def wire() -> Iterator[Wire]:
with wire_server(_image_host_and_clef) as served:
yield served
_SLOW_IMAGE_RELEASE.set()
@pytest.fixture(scope="module")
def sink() -> Iterator[Wire]:
with wire_server(lambda _: Reply()) as served:
yield served
@pytest.fixture(scope="module")
def allowlisted(rig: Gateway, wire: Wire, sink: Wire, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
directory: Final = tmp_path_factory.mktemp("clef-images-allowlisted")
(directory / "image_input_recorder.py").write_text(_IMAGE_INPUT_RECORDER)
config: Final = _JSON_OBJECT.validate_python(
yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
)
path: Final = directory / "clef-images-allowlisted.yaml"
path.write_text(
yaml.safe_dump(
{
**config,
"model_list": [
{
"model_name": _ALLOWLISTED_MODEL,
"litellm_params": {"model": _CLEF, "api_base": wire.url, "api_key": _API_KEY},
}
],
"litellm_settings": {
**object_value(config["litellm_settings"]),
"callbacks": ["image_input_recorder.instance"],
},
"general_settings": {
**object_value(config["general_settings"]),
"user_url_allowed_hosts": ["127.0.0.1"],
},
}
)
)
overrides: Final = {
"DATABASE_URL": _pooled_database_url(os.environ["DATABASE_URL"]),
"IMAGE_INPUT_SINK": sink.url,
}
with owned_proxy(rig, directory, overrides, config=path) as owned:
yield owned
@pytest.fixture(autouse=True)
def _drained(request: pytest.FixtureRequest) -> None:
for name in ("wire", "sink"):
if name in request.fixturenames:
request.getfixturevalue(name).drain()
def _routes(received: Sequence[Request]) -> list[tuple[str, str]]:
return [(request.method, urlsplit(unquote(request.target)).path) for request in received]
def _logged_inputs(sink: Wire, call_ids: Sequence[str]) -> dict[str, JsonValue]:
eventually(sink.received.qsize, lambda pending: pending >= len(call_ids), seconds=30)
events: Final = tuple(_JSON_OBJECT.validate_json(request.body) for request in sink.drain())
return {str(event["call_id"]): event["input"] for event in events}
@pytest.mark.timeout(_OWNED_TIMEOUT)
@pytest.mark.parametrize("route", ("/v1/systemone", "/v1/decisions"))
def test_an_allowlisted_remote_image_reaches_clef_as_bytes_while_callbacks_log_the_url_the_caller_sent(
allowlisted: Gateway, wire: Wire, sink: Wire, route: Route
) -> None:
marker: Final = uuid.uuid4().hex
image_url: Final = f"{wire.url}/image.png?id=marker-{marker}"
responses: Final = tuple(_decide(allowlisted, route, _ALLOWLISTED_MODEL, marker, [image_url]) for _ in range(2))
for response in responses:
assert response.status_code == 200, response.text
assert response.json() == _expected_response(route, "clef", marker)
received: Final = wire.drain()
posts: Final = tuple(request for request in received if request.method == "POST")
assert {target for method, target in _routes(received) if method == "GET"} == {"/image.png"}, _routes(received)
assert [_upstream_body(request) for request in posts] == [
_expected_upstream_body("clef", _state(marker), [_PNG_DATA_URL])
] * 2
call_ids: Final = tuple(response.headers["x-litellm-call-id"] for response in responses)
assert len(set(call_ids)) == 2, call_ids
assert _logged_inputs(sink, call_ids) == {
call_id: _expected_upstream_body("clef", _state(marker), [image_url]) for call_id in call_ids
}
@pytest.mark.timeout(_OWNED_TIMEOUT)
def test_a_missing_remote_image_is_refused_without_a_call_to_clef(allowlisted: Gateway, wire: Wire) -> None:
marker: Final = uuid.uuid4().hex
response: Final = _decide(
allowlisted, "/v1/systemone", _ALLOWLISTED_MODEL, marker, [f"{wire.url}/missing.png?id=marker-{marker}"]
)
assert response.status_code == 400, response.text
assert "Unable to fetch image from URL. Status code: 404" in response.text, response.text
assert _routes(wire.drain()) == [("GET", "/missing.png")]
@pytest.mark.timeout(_OWNED_TIMEOUT)
def test_a_remote_page_that_is_not_an_image_is_refused_without_a_call_to_clef(allowlisted: Gateway, wire: Wire) -> None:
marker: Final = uuid.uuid4().hex
response: Final = _decide(
allowlisted, "/v1/decisions", _ALLOWLISTED_MODEL, marker, [f"{wire.url}/page.html?id=marker-{marker}"]
)
assert response.status_code == 400, response.text
assert "images, got text/html" in response.text, response.text
assert _routes(wire.drain()) == [("GET", "/page.html")]
@pytest.mark.timeout(_OWNED_TIMEOUT)
def test_a_hanging_image_host_gets_one_attempt_within_the_callers_timeout(allowlisted: Gateway, wire: Wire) -> None:
marker: Final = uuid.uuid4().hex
response: Final = _decide(
allowlisted,
"/v1/systemone",
_ALLOWLISTED_MODEL,
marker,
[f"{wire.url}/slow.png?id=marker-{marker}"],
timeout=2,
num_retries=0,
)
received: Final = wire.drain()
_SLOW_IMAGE_RELEASE.set()
assert response.status_code == 400, response.text
assert "Unable to fetch image from URL after 3 attempts" in response.text, response.text
assert _routes(received) == [("GET", "/slow.png")]

View file

@ -116,6 +116,7 @@ class _Provider:
wraps_result: bool
cost_map_key: str | None
speaks_openai: bool = False
takes_images: bool = False
ai_decide: bool = False
provider_reported_cost: float | None = None
@ -184,6 +185,7 @@ _PROVIDERS: Final = (
_API_KEY,
True,
"cloudflare/@cf/cloudflare/clef",
takes_images=True,
),
_Provider("hosted_vllm", "hosted_vllm/Qwen/Qwen3-0.6B", "/v1/systemone", "Qwen/Qwen3-0.6B", None, False, None),
_Provider(
@ -211,9 +213,11 @@ _PROVIDERS: Final = (
_Provider("openai", "openai/gpt-6-luna", "/v1/decisions", "gpt-6-luna", _API_KEY, False, "gpt-6-luna", True),
)
_SYSTEM_ONE_PROVIDERS: Final = tuple(provider for provider in _PROVIDERS if not provider.speaks_openai)
_TEXT_ONLY_PROVIDERS: Final = tuple(provider for provider in _SYSTEM_ONE_PROVIDERS if not provider.takes_images)
_PERPLEXITY: Final = _PROVIDERS[0]
_TYPESAFE: Final = _PROVIDERS[1]
_OPENAI: Final = next(provider for provider in _PROVIDERS if provider.speaks_openai)
_CLOUDFLARE: Final = next(provider for provider in _SYSTEM_ONE_PROVIDERS if provider.takes_images)
_PREDICATE: Final[dict[str, JsonValue]] = {"type": "predicate", "name": "q", "instructions": "Is it?"}
_INVALID_BODIES: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = (
("missing questions", {"input": _INPUT}),
@ -235,12 +239,13 @@ _INVALID_BODIES: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = (
("numeric safety_identifier", {"input": _INPUT, "questions": _QUESTIONS, "safety_identifier": 7}),
("list safety_identifier", {"input": _INPUT, "questions": _QUESTIONS, "safety_identifier": [_SAFETY_IDENTIFIER]}),
)
_PNG_DATA_URL: Final = "data:image/png;base64,iVBORw0KGgo="
_IMAGE_INPUT: Final[list[JsonValue]] = [
{
"role": "user",
"content": [
{"type": "input_text", "text": _INPUT},
{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo=", "detail": "auto"},
{"type": "input_image", "image_url": _PNG_DATA_URL, "detail": "auto"},
],
}
]
@ -250,7 +255,7 @@ _OPENAI_IMAGE_MESSAGES: Final[list[JsonValue]] = [
"type": "message",
"content": [
{"type": "input_text", "text": _INPUT},
{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo=", "detail": "auto"},
{"type": "input_image", "image_url": _PNG_DATA_URL, "detail": "auto"},
],
}
]
@ -337,6 +342,13 @@ def _assert_refused_as_invalid(gateway: Gateway, model: str, label: str, body: d
assert "Invalid Decisions request" in response.text, (label, response.text)
def _decide_with_images_in_both_formats(gateway: Gateway, model: str) -> tuple[httpx.Response, httpx.Response]:
return (
_decide(gateway, model, input=_IMAGE_INPUT),
_decide_in_system_one_format(gateway, model, images=[_PNG_DATA_URL]),
)
def _assert_refused_for_safety_identifier(response: httpx.Response) -> None:
assert response.status_code == 400, response.text
assert "safety_identifier" in response.text and "drop_params" in response.text, response.text
@ -462,18 +474,37 @@ def test_invalid_bodies_are_refused_at_the_gateway_without_an_upstream_call(gate
assert _upstream_calls(gateway, handle) == []
@pytest.mark.parametrize("provider", _SYSTEM_ONE_PROVIDERS, ids=_provider_id)
def test_system_one_providers_refuse_images_at_the_gateway_without_an_upstream_call(
@pytest.mark.parametrize("provider", _TEXT_ONLY_PROVIDERS, ids=_provider_id)
def test_text_only_providers_refuse_images_unless_the_deployment_drops_params(
gateway: Gateway, provider: _Provider
) -> None:
with gateway.scenario() as scenario:
handle: Final = _register(scenario, provider.upstream_reply())
model: Final = _deployment(scenario, handle, provider)
response: Final = _decide(gateway, model, input=_IMAGE_INPUT)
assert response.status_code == 400, response.text
assert "input_image" in response.text
strict: Final = _deployment(scenario, handle, provider)
openai_format, system_one_format = _decide_with_images_in_both_formats(gateway, strict)
assert (openai_format.status_code, system_one_format.status_code) == (400, 400), openai_format.text
assert "['input_image']" in openai_format.text and "drop_params" in openai_format.text, openai_format.text
assert "['images']" in system_one_format.text, system_one_format.text
assert _upstream_calls(gateway, handle) == []
dropping: Final = _deployment(scenario, handle, provider, drop_params=True)
accepted: Final = _decide_with_images_in_both_formats(gateway, dropping)
assert [response.status_code for response in accepted] == [200, 200], [response.text for response in accepted]
assert [call["body"] for call in _upstream_calls(gateway, handle)] == [provider.upstream_body()] * 2
def test_cloudflare_receives_images_from_either_format_in_its_images_field(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
handle: Final = _register(scenario, _CLOUDFLARE.upstream_reply())
model: Final = _deployment(scenario, handle, _CLOUDFLARE)
openai_format, system_one_format = _decide_with_images_in_both_formats(gateway, model)
assert openai_format.status_code == 200, openai_format.text
assert openai_format.json() == _CLOUDFLARE.litellm_response()
assert system_one_format.status_code == 200, system_one_format.text
assert system_one_format.json()["answers"] == _SYSTEM_ONE_ANSWERS
expected_body: Final = {**_CLOUDFLARE.upstream_body(), "images": [_PNG_DATA_URL]}
assert [call["body"] for call in _upstream_calls(gateway, handle)] == [expected_body] * 2
def test_openai_forwards_image_input_in_its_own_message_shape(gateway: Gateway) -> None:
with gateway.scenario() as scenario:

View file

@ -1,10 +1,11 @@
from __future__ import annotations
import asyncio
import base64
import json
import re
import threading
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from concurrent.futures import wait
from types import MappingProxyType
from typing import Final
@ -22,6 +23,7 @@ from litellm.cost_calculator import get_response_cost_from_hidden_params
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.bedrock.base_aws_llm import AWS_SIGNING_EXECUTOR
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.types.decisions import (
ChoiceAnswer,
DecisionsResponse,
@ -1539,20 +1541,16 @@ async def test_openai_format_calls_to_a_systemone_provider_get_openai_format_ans
("model", "request_kwargs", "message"),
(
(
"typesafe/jev-1.13",
"openai/gpt-6-luna",
{
"input": [
{
"role": "user",
"content": [{"type": "input_image", "image_url": "data:image/png;base64,AA=="}],
}
],
"input": "review",
"images": ["data:image/png;base64,AA=="],
"questions": [{"type": "predicate", "instructions": "Is this a defect?"}],
},
"cannot serve this request",
"send images as input_image content parts",
),
),
ids=("image_to_systemone_provider",),
ids=("images_and_input",),
)
async def test_requests_a_provider_cannot_serve_are_rejected_before_http(
respx_mock: respx.MockRouter,
@ -1735,6 +1733,776 @@ async def test_non_string_safety_identifier_is_dropped_from_the_wire_under_drop_
}
_CLOUDFLARE_RUN_URL: Final = "https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare"
_PERPLEXITY_DECISIONS_URL: Final = "https://api.perplexity.ai/v1/decisions"
_PNG_DATA_URL: Final = "data:image/png;base64,AA=="
_REMOTE_IMAGE_URL: Final = "https://images.example/screen.png"
_SCREEN_TEXT: Final = "Is the screen cracked?"
_SYSTEMONE_PREDICATE: Final[Mapping[str, object]] = MappingProxyType(
{"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}
)
def _screen_messages(image_url: str) -> tuple[Mapping[str, object], ...]:
return (
{
"role": "user",
"content": [
{"type": "input_text", "text": _SCREEN_TEXT},
{"type": "input_image", "image_url": image_url, "detail": "high"},
],
},
)
def _image_requests(image_url: str) -> tuple[tuple[str, Mapping[str, object]], ...]:
return (
("decisions", MappingProxyType({"input": _screen_messages(image_url), "questions": _PREDICATE_QUESTIONS})),
(
"systemone",
MappingProxyType({"state": _SCREEN_TEXT, "questions": _SYSTEMONE_PREDICATE, "images": [image_url]}),
),
)
@pytest.fixture
def cloudflare_account(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct")
monkeypatch.setenv("CLOUDFLARE_API_KEY", "cloudflare-key")
monkeypatch.delenv("CLOUDFLARE_API_BASE", raising=False)
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize("model", ("clef", "clef-flash"))
async def test_input_images_reach_cloudflare_clef_as_its_images_field(model: str, respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/{model}").respond(json=_RESPONSE)
response: Final = await litellm.adecisions(
model=f"cloudflare/{model}",
input=(
*_screen_messages(_PNG_DATA_URL),
{"role": "user", "content": [{"type": "input_image", "image_url": "data:image/webp;base64,BB=="}]},
{"role": "user", "content": "Order 1234."},
),
questions=_PREDICATE_QUESTIONS,
)
assert json.loads(route.calls[0].request.content) == {
"model": model,
"state": f"{_SCREEN_TEXT}\n\nOrder 1234.",
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
"images": [_PNG_DATA_URL, "data:image/webp;base64,BB=="],
}
assert isinstance(response, OpenAIDecisionResponse)
@pytest.mark.usefixtures("cloudflare_account")
def test_systemone_images_reach_cloudflare_clef_in_the_callers_form(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
response: Final = litellm.systemone(
model="cloudflare/clef",
state={"ticket": 1234},
questions=_SYSTEMONE_PREDICATE,
images=[_PNG_DATA_URL, {"content_type": "image/webp", "base64": "BB=="}],
)
assert json.loads(route.calls[0].request.content) == {
"model": "clef",
"state": {"ticket": 1234},
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
"images": [_PNG_DATA_URL, {"content_type": "image/webp", "base64": "BB=="}],
}
assert isinstance(response, DecisionsResponse)
@pytest.mark.asyncio
class _InputRecordingLogger(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.inputs: list[object] = []
async def async_log_success_event(
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
) -> None:
self.inputs.append(kwargs.get("input"))
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize(("api", "request_kwargs"), _image_requests(_REMOTE_IMAGE_URL), ids=_PREDICATE_REQUEST_IDS)
async def test_logging_callbacks_get_the_image_url_the_caller_sent_while_cloudflare_gets_its_bytes(
api: str,
request_kwargs: Mapping[str, object],
respx_mock: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
async_only_image_fetch,
) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
recording_logger: Final = _InputRecordingLogger()
monkeypatch.setattr(litellm, "callbacks", [recording_logger])
await getattr(litellm, f"a{api}")(model="cloudflare/clef", **request_kwargs)
await _drain_logging_worker()
wire_body: Final = json.loads(route.calls[0].request.content)
assert wire_body["images"] == [async_only_image_fetch.data_url]
assert recording_logger.inputs == [{**wire_body, "images": [_REMOTE_IMAGE_URL]}]
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize(("api", "request_kwargs"), _image_requests(_REMOTE_IMAGE_URL), ids=_PREDICATE_REQUEST_IDS)
async def test_remote_images_are_fetched_off_the_event_loop_and_inlined_for_cloudflare(
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, async_only_image_fetch
) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
await getattr(litellm, f"a{api}")(model="cloudflare/clef", **request_kwargs)
assert async_only_image_fetch.fetched == [_REMOTE_IMAGE_URL]
assert json.loads(route.calls[0].request.content)["images"] == [async_only_image_fetch.data_url]
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize(("api", "request_kwargs"), _image_requests(_REMOTE_IMAGE_URL), ids=_PREDICATE_REQUEST_IDS)
def test_sync_decisions_fetch_and_inline_remote_images_for_cloudflare(
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", False)
png: Final = b"\x89PNG\r\n\x1a\n"
image_route: Final = respx_mock.get(_REMOTE_IMAGE_URL).respond(content=png, headers={"content-type": "image/png"})
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
getattr(litellm, api)(model="cloudflare/clef", **request_kwargs)
assert image_route.call_count == 1
assert json.loads(route.calls[0].request.content)["images"] == [
f"data:image/png;base64,{base64.b64encode(png).decode()}"
]
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize("sync", (False, True), ids=("async", "sync"))
async def test_remote_image_downloads_use_the_callers_timeout(
sync: bool, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", False)
monkeypatch.setattr(litellm, "module_level_aclient", AsyncHTTPHandler(transport=httpx.AsyncHTTPTransport()))
image_route: Final = respx_mock.get(_REMOTE_IMAGE_URL).respond(
content=b"\x89PNG\r\n\x1a\n", headers={"content-type": "image/png"}
)
respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
request_kwargs: Final = {**_image_requests(_REMOTE_IMAGE_URL)[1][1], "model": "cloudflare/clef", "timeout": 7.0}
if sync:
litellm.systemone(**request_kwargs)
else:
await litellm.asystemone(**request_kwargs)
sent_timeout: Final = image_route.calls.last.request.extensions["timeout"]
assert sent_timeout.keys() == httpx.Timeout(7.0).as_dict().keys()
assert all(6.0 < seconds <= 7.0 for seconds in sent_timeout.values()), sent_timeout
@pytest.mark.asyncio
async def test_remote_input_images_reach_openai_unfetched(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, async_only_image_fetch
) -> None:
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
route: Final = respx_mock.post("https://api.openai.com/v1/decisions").respond(json=_OPENAI_RESPONSE)
await litellm.adecisions(
model="openai/gpt-6-luna",
input=_screen_messages(_REMOTE_IMAGE_URL),
questions=_PREDICATE_QUESTIONS,
api_key="caller-key",
)
assert json.loads(route.calls[0].request.content)["input"][0]["content"][1]["image_url"] == _REMOTE_IMAGE_URL
assert async_only_image_fetch.fetched == []
_IMAGE_REFUSALS: Final[tuple[tuple[str, Mapping[str, object], str], ...]] = (
("decisions", _image_requests(_PNG_DATA_URL)[0][1], "input_image"),
("systemone", _image_requests(_PNG_DATA_URL)[1][1], "images"),
)
@pytest.mark.parametrize(("api", "request_kwargs", "param"), _IMAGE_REFUSALS, ids=_PREDICATE_REQUEST_IDS)
def test_images_are_refused_before_http_when_the_provider_cannot_take_them(
api: str,
request_kwargs: Mapping[str, object],
param: str,
respx_mock: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE)
with pytest.raises(litellm.UnsupportedParamsError, match=rf"\['{param}'\].*drop_params") as caught:
getattr(litellm, api)(model="perplexity/pplx-decider-v1-27b", api_key="caller-key", **request_kwargs)
assert caught.value.status_code == 400
assert not route.called
@pytest.mark.asyncio
@pytest.mark.parametrize(("api", "request_kwargs"), _image_requests(_PNG_DATA_URL), ids=_PREDICATE_REQUEST_IDS)
async def test_images_are_dropped_from_the_wire_under_drop_params(
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE)
await getattr(litellm, f"a{api}")(
model="perplexity/pplx-decider-v1-27b", api_key="caller-key", drop_params=True, **request_kwargs
)
assert json.loads(route.calls[0].request.content) == {
"model": "pplx-decider-v1-27b",
"state": _SCREEN_TEXT,
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
_MALFORMED_IMAGE_ENTRIES: Final[tuple[Mapping[str, object] | str, ...]] = (
{"url": _PNG_DATA_URL},
{"type": "image_url", "image_url": {"url": _PNG_DATA_URL}},
{"content_type": "image/png", "base64": "AA==", "detail": "high"},
{"content_type": "text/plain", "base64": "AA=="},
{"content_type": "image/png", "base64": ""},
"a" * 5000,
"data:text/plain;base64,AA==",
)
_MALFORMED_IMAGE_ENTRY_IDS: Final = (
"url_object",
"chat_image_url",
"extra_key",
"non_image_type",
"empty_base64",
"not_a_url",
"non_image_data_url",
)
_MALFORMED_IMAGES: Final[tuple[object, ...]] = (_PNG_DATA_URL, *([entry] for entry in _MALFORMED_IMAGE_ENTRIES))
_MALFORMED_IMAGE_IDS: Final = ("bare_string", *_MALFORMED_IMAGE_ENTRY_IDS)
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize("images", _MALFORMED_IMAGES, ids=_MALFORMED_IMAGE_IDS)
def test_malformed_images_are_rejected_before_http(
images: object, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match=r"(?s)Invalid Decisions request.*images"):
litellm.systemone(model="cloudflare/clef", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=images)
assert not route.called
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize("images", _MALFORMED_IMAGES, ids=_MALFORMED_IMAGE_IDS)
async def test_malformed_images_are_dropped_from_the_wire_under_drop_params(
images: object, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
await litellm.asystemone(
model="cloudflare/clef", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=images, drop_params=True
)
assert json.loads(route.calls[0].request.content) == {
"model": "clef",
"state": _SCREEN_TEXT,
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize("entry", _MALFORMED_IMAGE_ENTRIES, ids=_MALFORMED_IMAGE_ENTRY_IDS)
async def test_a_malformed_image_under_drop_params_keeps_the_well_formed_ones(
entry: Mapping[str, object] | str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
await litellm.asystemone(
model="cloudflare/clef",
state=_SCREEN_TEXT,
questions=_SYSTEMONE_PREDICATE,
images=[entry, _PNG_DATA_URL, {"content_type": "image/png", "base64": "AA=="}],
drop_params=True,
)
assert json.loads(route.calls[0].request.content)["images"] == [
_PNG_DATA_URL,
{"content_type": "image/png", "base64": "AA=="},
]
@pytest.mark.asyncio
async def test_empty_images_next_to_input_are_ignored(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
route: Final = respx_mock.post("https://api.openai.com/v1/decisions").respond(json=_OPENAI_RESPONSE)
await litellm.adecisions(
model="openai/gpt-6-luna",
api_key="caller-key",
input="review",
questions=_PREDICATE_QUESTIONS,
images=[],
)
assert json.loads(route.calls[0].request.content) == {
"model": "gpt-6-luna",
"input": "review",
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
}
@pytest.mark.asyncio
async def test_images_next_to_input_are_dropped_under_drop_params(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
route: Final = respx_mock.post("https://api.openai.com/v1/decisions").respond(json=_OPENAI_RESPONSE)
await litellm.adecisions(
model="openai/gpt-6-luna",
api_key="caller-key",
input="review",
questions=_PREDICATE_QUESTIONS,
images=[_PNG_DATA_URL],
drop_params=True,
)
assert json.loads(route.calls[0].request.content) == {
"model": "gpt-6-luna",
"input": "review",
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
}
def _cloudflare_image_requests(count: int) -> tuple[tuple[str, Mapping[str, object]], ...]:
return (
(
"decisions",
MappingProxyType(
{
"input": (
{
"role": "user",
"content": [{"type": "input_image", "image_url": _PNG_DATA_URL} for _ in range(count)],
},
),
"questions": _PREDICATE_QUESTIONS,
}
),
),
(
"systemone",
MappingProxyType(
{"state": _SCREEN_TEXT, "questions": _SYSTEMONE_PREDICATE, "images": [_PNG_DATA_URL] * count}
),
),
)
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize(("api", "request_kwargs"), _cloudflare_image_requests(5), ids=_PREDICATE_REQUEST_IDS)
def test_more_images_than_cloudflare_takes_are_refused_before_any_download_or_http(
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", True)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match="at most 4 images per request, got 5") as caught:
getattr(litellm, api)(model="cloudflare/clef", **request_kwargs)
assert caught.value.status_code == 400
assert not route.called
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize(("api", "request_kwargs"), _cloudflare_image_requests(4), ids=_PREDICATE_REQUEST_IDS)
def test_the_four_images_cloudflare_takes_reach_it(
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter
) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
getattr(litellm, api)(model="cloudflare/clef", **request_kwargs)
assert json.loads(route.calls[0].request.content)["images"] == [_PNG_DATA_URL] * 4
@pytest.mark.asyncio
async def test_dropping_the_only_input_images_is_rejected_before_http(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match=r"\['input_image'\].*leaves no input") as caught:
await litellm.adecisions(
model="perplexity/pplx-decider-v1-27b",
api_key="caller-key",
input=[{"role": "user", "content": [{"type": "input_image", "image_url": _PNG_DATA_URL}]}],
questions=_PREDICATE_QUESTIONS,
drop_params=True,
)
assert caught.value.status_code == 400
assert not route.called
_MALFORMED_IMAGE_URLS: Final = ("not-an-image", "data:application/pdf;base64,AA==", "ftp://images.example/a.png")
_MALFORMED_IMAGE_URL_IDS: Final = ("not_a_url", "pdf_data_url", "ftp_url")
_OPENAI_DECISIONS_URL: Final = "https://api.openai.com/v1/decisions"
@pytest.fixture
def openai_default_base(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
@pytest.mark.usefixtures("cloudflare_account", "openai_default_base")
@pytest.mark.parametrize(
("model", "upstream"),
(("cloudflare/clef", f"{_CLOUDFLARE_RUN_URL}/clef"), ("openai/gpt-6-luna", _OPENAI_DECISIONS_URL)),
ids=("clef", "luna"),
)
@pytest.mark.parametrize("image_url", _MALFORMED_IMAGE_URLS, ids=_MALFORMED_IMAGE_URL_IDS)
def test_malformed_input_image_urls_are_rejected_before_http(
model: str, upstream: str, image_url: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(upstream).respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match=r"Invalid Decisions request: 1 input_image") as caught:
litellm.decisions(
model=model, api_key="caller-key", input=_screen_messages(image_url), questions=_PREDICATE_QUESTIONS
)
assert caught.value.status_code == 400
assert not route.called
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize("image_url", _MALFORMED_IMAGE_URLS, ids=_MALFORMED_IMAGE_URL_IDS)
async def test_a_malformed_input_image_under_drop_params_keeps_the_text_and_well_formed_images(
image_url: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
await litellm.adecisions(
model="cloudflare/clef",
input=(
*_screen_messages(image_url),
{"role": "user", "content": [{"type": "input_image", "image_url": image_url}]},
{"role": "user", "content": [{"type": "input_image", "image_url": _PNG_DATA_URL}]},
),
questions=_PREDICATE_QUESTIONS,
drop_params=True,
)
assert json.loads(route.calls[0].request.content) == {
"model": "clef",
"state": _SCREEN_TEXT,
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
"images": [_PNG_DATA_URL],
}
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
async def test_dropping_the_only_malformed_input_images_is_rejected_before_http(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match=r"Invalid Decisions request: 2 input_image") as caught:
await litellm.adecisions(
model="cloudflare/clef",
input=[
{"role": "user", "content": [{"type": "input_image", "image_url": url}]}
for url in _MALFORMED_IMAGE_URLS[:2]
],
questions=_PREDICATE_QUESTIONS,
drop_params=True,
)
assert caught.value.status_code == 400
assert not route.called
_MIB: Final = 1024 * 1024
def _png_data_url(decoded_bytes: int) -> str:
return f"data:image/png;base64,{base64.b64encode(bytes(decoded_bytes)).decode()}"
def _png_object(decoded_bytes: int) -> Mapping[str, object]:
return {"content_type": "image/png", "base64": base64.b64encode(bytes(decoded_bytes)).decode()}
_OVERSIZED_IMAGES: Final[tuple[tuple[Sequence[object], str], ...]] = (
((_png_data_url(4 * _MIB + 1),), "images of at most 4,194,304 bytes (4 MiB) each, got one of 4,194,305 bytes"),
((_png_object(4 * _MIB + 1),), "images of at most 4,194,304 bytes (4 MiB) each, got one of 4,194,305 bytes"),
(
(_png_data_url(4 * _MIB), _png_object(4 * _MIB), _png_data_url(1)),
"at most 8,388,608 bytes (8 MiB) of images per request, got 8,388,609 bytes",
),
)
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize("drop_params", (False, True), ids=("strict", "drop_params"))
@pytest.mark.parametrize(
("images", "refusal"), _OVERSIZED_IMAGES, ids=("data_url_over_4mib", "object_over_4mib", "total_over_8mib")
)
def test_images_over_cloudflares_size_limits_are_refused_before_http(
images: Sequence[object], refusal: str, drop_params: bool, respx_mock: respx.MockRouter
) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match=re.escape(f"cloudflare takes {refusal}")) as caught:
litellm.systemone(
model="cloudflare/clef",
state=_SCREEN_TEXT,
questions=_SYSTEMONE_PREDICATE,
images=images,
drop_params=drop_params,
)
assert caught.value.status_code == 400
assert not route.called
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
async def test_images_at_cloudflares_size_limits_reach_it(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
at_limits: Final = (_png_data_url(4 * _MIB), _png_object(4 * _MIB))
await litellm.adecisions(
model="cloudflare/clef",
input=[{"role": "user", "content": [{"type": "input_image", "image_url": _png_data_url(4 * _MIB)}]}],
questions=_PREDICATE_QUESTIONS,
)
await litellm.asystemone(
model="cloudflare/clef", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=at_limits
)
assert json.loads(route.calls[0].request.content)["images"] == [_png_data_url(4 * _MIB)]
assert json.loads(route.calls[1].request.content)["images"] == list(at_limits)
@pytest.mark.usefixtures("cloudflare_account")
def test_a_downloaded_image_over_cloudflares_limit_is_refused_before_the_cloudflare_call(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", False)
image_route: Final = respx_mock.get(_REMOTE_IMAGE_URL).respond(
content=bytes(4 * _MIB + 1), headers={"content-type": "image/png"}
)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match="got one of 4,194,305 bytes") as caught:
litellm.systemone(
model="cloudflare/clef", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=[_REMOTE_IMAGE_URL]
)
assert caught.value.status_code == 400
assert image_route.call_count == 1
assert not route.called
@pytest.mark.usefixtures("cloudflare_account")
def test_remote_image_urls_with_an_uppercase_scheme_are_inlined_for_cloudflare(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", False)
png: Final = b"\x89PNG\r\n\x1a\n"
image_route: Final = respx_mock.get(_REMOTE_IMAGE_URL).respond(content=png, headers={"content-type": "image/png"})
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
litellm.systemone(
model="cloudflare/clef",
state=_SCREEN_TEXT,
questions=_SYSTEMONE_PREDICATE,
images=[_REMOTE_IMAGE_URL.replace("https://", "HTTPS://")],
)
assert image_route.call_count == 1
assert json.loads(route.calls[0].request.content)["images"] == [
f"data:image/png;base64,{base64.b64encode(png).decode()}"
]
_PNG_DATA_URL_WITH_NAME: Final = "data:image/png;name=screen.png;base64,AA=="
_GIF_DATA_URL: Final = "data:image/gif;base64,R0lGODlh"
_CLOUDFLARE_TYPE_REFUSAL: Final = "cloudflare takes only image/jpeg, image/jpg, image/png, image/webp images, got "
@pytest.mark.usefixtures("openai_default_base")
def test_a_data_url_with_media_type_parameters_reaches_openai_unchanged(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(_OPENAI_DECISIONS_URL).respond(json=_OPENAI_RESPONSE)
litellm.decisions(
model="openai/gpt-6-luna",
api_key="caller-key",
input=_screen_messages(_PNG_DATA_URL_WITH_NAME),
questions=_PREDICATE_QUESTIONS,
)
assert json.loads(route.calls[0].request.content)["input"][0]["content"][1]["image_url"] == _PNG_DATA_URL_WITH_NAME
@pytest.mark.usefixtures("cloudflare_account")
def test_cloudflare_gets_images_without_media_type_parameters(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
litellm.systemone(
model="cloudflare/clef",
state=_SCREEN_TEXT,
questions=_SYSTEMONE_PREDICATE,
images=[
_PNG_DATA_URL_WITH_NAME.replace("image/png", "IMAGE/PNG"),
{"content_type": "image/webp;name=screen.webp", "base64": "BB=="},
],
)
assert json.loads(route.calls[0].request.content)["images"] == [
_PNG_DATA_URL,
{"content_type": "image/webp", "base64": "BB=="},
]
_REFUSED_TYPE_REQUESTS: Final[tuple[tuple[str, Mapping[str, object]], ...]] = (
("decisions", {"input": _screen_messages(_GIF_DATA_URL), "questions": _PREDICATE_QUESTIONS}),
(
"systemone",
{
"state": _SCREEN_TEXT,
"questions": _SYSTEMONE_PREDICATE,
"images": [{"content_type": "image/gif", "base64": "R0lGODlh"}],
},
),
)
@pytest.mark.usefixtures("cloudflare_account")
@pytest.mark.parametrize(("api", "request_kwargs"), _REFUSED_TYPE_REQUESTS, ids=("input_image", "images_object"))
def test_an_image_type_cloudflare_refuses_is_rejected_before_http(
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match=re.escape(f"{_CLOUDFLARE_TYPE_REFUSAL}image/gif")) as caught:
getattr(litellm, api)(model="cloudflare/clef", **request_kwargs)
assert caught.value.status_code == 400
assert not route.called
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
async def test_an_image_type_cloudflare_refuses_is_dropped_under_drop_params(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
await litellm.adecisions(
model="cloudflare/clef",
input=(
*_screen_messages(_GIF_DATA_URL),
{"role": "user", "content": [{"type": "input_image", "image_url": _PNG_DATA_URL}]},
),
questions=_PREDICATE_QUESTIONS,
drop_params=True,
)
with pytest.raises(litellm.BadRequestError, match=re.escape(f"{_CLOUDFLARE_TYPE_REFUSAL}image/gif")):
await litellm.adecisions(
model="cloudflare/clef",
input=[{"role": "user", "content": [{"type": "input_image", "image_url": _GIF_DATA_URL}]}],
questions=_PREDICATE_QUESTIONS,
drop_params=True,
)
assert route.call_count == 1
assert json.loads(route.calls[0].request.content)["images"] == [_PNG_DATA_URL]
@pytest.mark.asyncio
@pytest.mark.usefixtures("cloudflare_account")
async def test_an_image_dropped_under_drop_params_does_not_count_toward_the_cloudflare_limit(
respx_mock: respx.MockRouter,
) -> None:
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
gif_then_four_pngs: Final = (
{
"role": "user",
"content": [
{"type": "input_text", "text": _SCREEN_TEXT},
*({"type": "input_image", "image_url": url} for url in (_GIF_DATA_URL, *(_PNG_DATA_URL,) * 4)),
],
},
)
litellm.decisions(
model="cloudflare/clef", input=gif_then_four_pngs, questions=_PREDICATE_QUESTIONS, drop_params=True
)
await litellm.adecisions(
model="cloudflare/clef", input=gif_then_four_pngs, questions=_PREDICATE_QUESTIONS, drop_params=True
)
assert [json.loads(call.request.content)["images"] for call in route.calls] == [[_PNG_DATA_URL] * 4] * 2
@pytest.mark.usefixtures("cloudflare_account")
def test_a_downloaded_page_that_is_not_an_image_is_refused_before_the_cloudflare_call(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", False)
image_route: Final = respx_mock.get(_REMOTE_IMAGE_URL).respond(
content=b"<html></html>", headers={"content-type": "text/html; charset=utf-8"}
)
route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)
with pytest.raises(litellm.BadRequestError, match=re.escape(f"{_CLOUDFLARE_TYPE_REFUSAL}text/html")) as caught:
litellm.systemone(
model="cloudflare/clef", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=[_REMOTE_IMAGE_URL]
)
assert caught.value.status_code == 400
assert image_route.call_count == 1
assert not route.called
@pytest.mark.asyncio
@pytest.mark.parametrize(
"api_base",
@ -1973,7 +2741,9 @@ async def test_clips_are_dropped_from_the_wire_for_a_model_without_media_support
call: Final = getattr(litellm, f"a{api}")
await call(model="perplexity/pplx-decider-v1-27b", api_key="caller-key", **request_kwargs)
await call(model="perplexity/pplx-decider-v1-27b", api_key="caller-key", drop_params=True, **request_kwargs, **clips)
await call(
model="perplexity/pplx-decider-v1-27b", api_key="caller-key", drop_params=True, **request_kwargs, **clips
)
assert len(route.calls) == 2
text_only_body: Final = json.loads(route.calls[0].request.content)
@ -2099,7 +2869,12 @@ async def test_media_support_is_read_per_kind_from_the_cost_map(
(
"typesafe/jev-chatty",
"https://example.com/v1/systemone",
{"litellm_provider": "typesafe", "mode": "chat", "supports_audio_input": True, "supports_video_input": True},
{
"litellm_provider": "typesafe",
"mode": "chat",
"supports_audio_input": True,
"supports_video_input": True,
},
),
("openai/gpt-audio", "https://example.com/v1/decisions", None),
),
@ -2121,7 +2896,9 @@ async def test_a_chat_models_media_flags_do_not_count_for_decisions(
audio: Final = ["data:audio/wav;base64,YQ=="]
videos: Final = ["data:video/mp4;base64,YQ=="]
with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("does not support parameters: ['audio', 'videos']")):
with pytest.raises(
litellm.UnsupportedParamsError, match=re.escape("does not support parameters: ['audio', 'videos']")
):
await litellm.asystemone(
model=model,
state="test",

View file

@ -3,13 +3,11 @@ import base64
import copy
import time
import uuid
from unittest.mock import patch
import pytest
from httpx import Request, Response
import litellm
from litellm import constants
from litellm.litellm_core_utils.prompt_templates import image_handling
from litellm.litellm_core_utils.prompt_templates.image_handling import (
MAX_CONCURRENT_REMOTE_MEDIA_FETCHES,
@ -275,9 +273,7 @@ def _stub_image_client(content, content_type):
def test_convert_url_to_base64_infers_the_type_when_the_server_sends_octet_stream(monkeypatch):
monkeypatch.setattr(
litellm, "module_level_client", _stub_image_client(REAL_PNG_BYTES, "application/octet-stream")
)
monkeypatch.setattr(litellm, "module_level_client", _stub_image_client(REAL_PNG_BYTES, "application/octet-stream"))
result = convert_url_to_base64(f"http://img.example/{uuid.uuid4()}")
@ -285,9 +281,7 @@ def test_convert_url_to_base64_infers_the_type_when_the_server_sends_octet_strea
def test_convert_url_to_base64_keeps_a_real_content_type(monkeypatch):
monkeypatch.setattr(
litellm, "module_level_client", _stub_image_client(REAL_PNG_BYTES, "image/jpeg")
)
monkeypatch.setattr(litellm, "module_level_client", _stub_image_client(REAL_PNG_BYTES, "image/jpeg"))
result = convert_url_to_base64(f"http://img.example/{uuid.uuid4()}.png")
@ -312,7 +306,7 @@ def test_image_size_limit_disabled(monkeypatch):
"""
Test that setting MAX_IMAGE_URL_DOWNLOAD_SIZE_MB to 0 disables all image URL downloads.
"""
import litellm.litellm_core_utils.prompt_templates.image_handling as image_handling
from litellm.litellm_core_utils.prompt_templates import image_handling
monkeypatch.setattr(litellm, "module_level_client", SmallImageClient())
monkeypatch.setattr(image_handling, "MAX_IMAGE_URL_DOWNLOAD_SIZE_MB", 0)
@ -580,6 +574,54 @@ def test_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeyp
_assert_verdict_free_messages(messages, url)
def test_convert_url_to_base64_retries_within_one_deadline(monkeypatch):
timeouts = []
def time_out(client, url, timeout=None, **kwargs):
timeouts.append(timeout)
time.sleep(timeout)
raise litellm.Timeout(message="timed out", model="", llm_provider="")
monkeypatch.setattr(image_handling, "safe_get", time_out)
started = time.monotonic()
with pytest.raises(litellm.ImageFetchError, match="after 3 attempts"):
convert_url_to_base64(f"https://slow.example/{uuid.uuid4()}.png", timeout=0.3)
assert time.monotonic() - started < 0.6
assert len(timeouts) == 1 and 0 < timeouts[0] <= 0.3
async def test_async_convert_url_to_base64_retries_within_one_deadline(monkeypatch):
timeouts = []
async def time_out(client, url, timeout=None, **kwargs):
timeouts.append(timeout)
await asyncio.sleep(timeout)
raise litellm.Timeout(message="timed out", model="", llm_provider="")
monkeypatch.setattr(image_handling, "async_safe_get", time_out)
started = time.monotonic()
with pytest.raises(litellm.ImageFetchError, match="after 3 attempts"):
await async_convert_url_to_base64(f"https://slow.example/{uuid.uuid4()}.png", timeout=0.3)
assert time.monotonic() - started < 0.6
assert len(timeouts) == 1 and 0 < timeouts[0] <= 0.3
def test_convert_url_to_base64_still_retries_fast_failures_three_times(monkeypatch):
attempts = []
def refuse(client, url, timeout=None, **kwargs):
attempts.append(timeout)
raise ConnectionError("refused")
monkeypatch.setattr(image_handling, "safe_get", refuse)
with pytest.raises(litellm.ImageFetchError, match="after 3 attempts"):
convert_url_to_base64(f"https://down.example/{uuid.uuid4()}.png", timeout=30.0)
assert len(attempts) == 3 and all(0 < timeout <= 30.0 for timeout in attempts)
async def test_async_inline_remote_media_caps_in_flight_fetches_per_request(monkeypatch):
in_flight = {"now": 0, "peak": 0}

View file

@ -94,11 +94,10 @@ def test_openai_messages_become_systemone_state_text() -> None:
body: Final = ir_to_systemone_request("jev-latest", ir)
assert isinstance(body, Mapping)
assert body["state"] == "The package arrived broken.\n\nI want a refund.\n\nOrder 1234."
def test_image_input_is_unsupported_by_systemone_providers() -> None:
def test_openai_input_images_reach_a_systemone_provider_as_its_images_in_message_order() -> None:
ir: Final = _openai_ir(
{
"input": [
@ -106,15 +105,27 @@ def test_image_input_is_unsupported_by_systemone_providers() -> None:
"role": "user",
"content": [
{"type": "input_text", "text": "Is the screen cracked?"},
{"type": "input_image", "image_url": "data:image/png;base64,AA=="},
{"type": "input_image", "image_url": "data:image/png;base64,AA==", "detail": "high"},
],
}
},
{"role": "user", "content": [{"type": "input_image", "image_url": "data:image/webp;base64,BB=="}]},
{"role": "user", "content": "Order 1234."},
],
"questions": [{"type": "predicate", "instructions": "Is this a defect?"}],
}
)
assert isinstance(ir_to_systemone_request("jev-latest", ir), UnsupportedDecisionsRequest)
body: Final = ir_to_systemone_request("clef", ir)
assert body["state"] == "Is the screen cracked?\n\nOrder 1234."
assert body["images"] == ["data:image/png;base64,AA==", "data:image/webp;base64,BB=="]
def test_systemone_images_reach_a_systemone_provider_in_the_callers_form() -> None:
images: Final = ["data:image/png;base64,AA==", {"content_type": "image/jpeg", "base64": "BB=="}]
ir: Final = systemone_request_to_ir(_SYSTEMONE_BODY.validate_python({**_SYSTEMONE_REQUEST, "images": images}))
assert ir_to_systemone_request("clef", ir) == {"model": "clef", **_SYSTEMONE_REQUEST, "images": images}
def test_the_largest_openai_request_accepted_translates_to_a_valid_systemone_request() -> None:

View file

@ -673,7 +673,7 @@ def test_transform_request_inlines_remote_image_urls(local_cost_map, monkeypatch
async def test_async_transform_request_inlines_remote_image_urls(local_cost_map, monkeypatch):
import litellm.litellm_core_utils.prompt_templates.image_handling as image_handling
async def fake_convert(url):
async def fake_convert(url, timeout=None):
return f"data:image/png;base64,{url}"
monkeypatch.setattr(image_handling, "async_convert_url_to_base64", fake_convert)

View file

@ -181,6 +181,32 @@ def test_systemone_questions_without_instructions_become_valid_openai_questions(
assert all(question.instructions for question in _OPENAI_BODY.validate_python(body).questions)
def test_systemone_images_reach_openai_as_input_images_ahead_of_the_state() -> None:
request: Final = _SYSTEMONE_BODY.validate_python(
{
"state": {"ticket": 1234},
"questions": {"damaged": {"type": "noul", "instructions": "Is the item damaged?"}},
"images": ["data:image/png;base64,AA==", {"content_type": "image/webp", "base64": "BB=="}],
}
)
assert ir_to_openai_request("gpt-6-luna", systemone_request_to_ir(request)) == {
"model": "gpt-6-luna",
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_image", "image_url": "data:image/png;base64,AA=="},
{"type": "input_image", "image_url": "data:image/webp;base64,BB=="},
{"type": "input_text", "text": '{"ticket": 1234}'},
],
}
],
"questions": [{"type": "predicate", "name": "damaged", "instructions": "Is the item damaged?"}],
}
@pytest.mark.parametrize(
"question",
({"type": "choice", "criteria": {"refund": None}}, {"type": "score", "criteria": ["minor"]}),

View file

@ -520,15 +520,8 @@ def test_decisions_return_guardrail_information_when_requested(
}
],
},
{
"model": "decider",
"input": [
{"role": "user", "content": [{"type": "input_image", "image_url": "data:image/png;base64,AA=="}]}
],
"questions": [{"type": "predicate", "instructions": "Is this a defect?"}],
},
),
ids=("systemone_body", "colliding_choice_values", "image_input"),
ids=("systemone_body", "colliding_choice_values"),
)
def test_openai_format_decisions_rejects_bodies_it_cannot_translate(
client: TestClient,
@ -804,6 +797,158 @@ def test_a_non_string_safety_identifier_is_refused_unless_the_deployment_drops_p
}
_IMAGE_TEXT: Final = "Is the screen cracked?"
_PNG_DATA_URL: Final = "data:image/png;base64,AA=="
_IMAGE_ENDPOINTS: Final = ("/v1/decisions", "/v1/systemone")
_IMAGE_REQUEST_BODIES: Final[tuple[Mapping[str, object], ...]] = (
{
"model": "decider",
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": _IMAGE_TEXT},
{"type": "input_image", "image_url": _PNG_DATA_URL},
],
}
],
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
},
{
"model": "decider",
"state": _IMAGE_TEXT,
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
"images": [_PNG_DATA_URL],
},
)
_IMAGE_REQUESTS: Final = tuple(zip(_IMAGE_ENDPOINTS, _IMAGE_REQUEST_BODIES, strict=True))
_IMAGE_REQUEST_IDS: Final = ("openai_format", "systemone_format")
def _route_decider_to(monkeypatch: pytest.MonkeyPatch, litellm_params: Mapping[str, object]) -> None:
monkeypatch.setattr(
litellm.proxy.proxy_server,
"llm_router",
litellm.Router(model_list=[{"model_name": "decider", "litellm_params": dict(litellm_params)}]),
)
@pytest.mark.parametrize(("endpoint", "request_body"), _IMAGE_REQUESTS, ids=_IMAGE_REQUEST_IDS)
def test_images_reach_a_cloudflare_clef_deployment_as_its_images_field(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
endpoint: str,
request_body: Mapping[str, object],
) -> None:
_route_decider_to(
monkeypatch,
{
"model": "cloudflare/clef",
"api_key": "cloudflare-key",
"api_base": "https://api.cloudflare.com/client/v4/accounts/acct/ai/run",
},
)
upstream: Final = respx_mock.post(
"https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef"
).respond(json=_RESPONSE)
response: Final = client.post(endpoint, json=request_body)
assert response.status_code == 200, response.text
assert json.loads(upstream.calls[0].request.content) == {
"model": "clef",
"state": _IMAGE_TEXT,
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
"images": [_PNG_DATA_URL],
}
@pytest.mark.parametrize(
("endpoint", "request_body", "param"),
tuple(zip(_IMAGE_ENDPOINTS, _IMAGE_REQUEST_BODIES, ("input_image", "images"), strict=True)),
ids=_IMAGE_REQUEST_IDS,
)
def test_images_to_a_text_only_deployment_are_refused_without_drop_params(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
endpoint: str,
request_body: Mapping[str, object],
param: str,
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
response: Final = client.post(endpoint, json=request_body)
assert response.status_code == 400, response.text
assert f"['{param}']" in response.json()["error"]["message"]
assert not upstream.called
@pytest.mark.parametrize(("endpoint", "request_body"), _IMAGE_REQUESTS, ids=_IMAGE_REQUEST_IDS)
def test_images_to_a_text_only_deployment_are_dropped_when_it_drops_params(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
endpoint: str,
request_body: Mapping[str, object],
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
_route_decider_to(
monkeypatch, {"model": "perplexity/pplx-decider-v1-27b", "api_key": "test-key", "drop_params": True}
)
upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
response: Final = client.post(endpoint, json=request_body)
assert response.status_code == 200, response.text
assert json.loads(upstream.calls[0].request.content) == {
"model": "pplx-decider-v1-27b",
"state": _IMAGE_TEXT,
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
@pytest.mark.parametrize("deployment_drops_params", (False, True), ids=("strict", "drop_params"))
def test_malformed_images_are_refused_unless_the_deployment_drops_params(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
deployment_drops_params: bool,
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
_route_decider_to(
monkeypatch,
{
"model": "cloudflare/clef",
"api_key": "cloudflare-key",
"api_base": "https://api.cloudflare.com/client/v4/accounts/acct/ai/run",
"drop_params": deployment_drops_params,
},
)
upstream: Final = respx_mock.post(
"https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef"
).respond(json=_RESPONSE)
response: Final = client.post(
"/v1/systemone", json={**_IMAGE_REQUEST_BODIES[1], "images": [{"url": _PNG_DATA_URL}]}
)
if not deployment_drops_params:
assert response.status_code == 400, response.text
assert "images" in response.json()["error"]["message"]
assert not upstream.called
return
assert response.status_code == 200, response.text
assert json.loads(upstream.calls[0].request.content) == {
"model": "clef",
"state": _IMAGE_TEXT,
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
@pytest.mark.parametrize("endpoint", ("/v1/systemone", "/v1/decisions"))
@pytest.mark.parametrize("deployment_drops_params", (False, True), ids=("strict", "drop_params"))
def test_clips_for_a_model_without_media_support_are_refused_unless_the_deployment_drops_params(

View file

@ -28,6 +28,9 @@ _SYSTEMONE_RESPONSE: Final[Mapping[str, object]] = {
"usage": {"input_tokens": 12, "output_tokens": 1},
}
_TYPESAFE_URL: Final = "https://api.typesafe.ai/v1/systemone"
_CLOUDFLARE_API_BASE: Final = "https://api.cloudflare.com/client/v4/accounts/acct/ai/run"
_CLOUDFLARE_CLEF_URL: Final = f"{_CLOUDFLARE_API_BASE}/@cf/cloudflare/clef"
_PNG_DATA_URL: Final = "data:image/png;base64,AA=="
@pytest.fixture(autouse=True)
@ -105,6 +108,25 @@ async def test_decisions_with_state_still_sends_the_systemone_format_and_warns(
assert response.answers["is_refund"].model_dump(mode="json") == {"type": "noul", "noul": 0.93}
@pytest.mark.asyncio
async def test_decisions_with_state_still_sends_its_images(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(_CLOUDFLARE_CLEF_URL).respond(json=_SYSTEMONE_RESPONSE)
with pytest.warns(UserWarning, match=r"litellm\.systemone"):
await litellm.adecisions(
model="cloudflare/clef",
state=_STATE,
questions=_SYSTEMONE_QUESTIONS,
images=[_PNG_DATA_URL],
api_key="caller-key",
api_base=_CLOUDFLARE_API_BASE,
)
sent: Final = json.loads(respx_mock.calls.last.request.content)
assert sent["images"] == [_PNG_DATA_URL]
assert route.called
@pytest.mark.asyncio
async def test_systemone_rejects_openai_question_lists_before_http(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(_TYPESAFE_URL).respond(json=_SYSTEMONE_RESPONSE)

View file

@ -71553,6 +71553,22 @@ export interface operations {
*/
content_type: string;
})[];
/**
* Images
* @description Images as data URLs or content_type plus base64, for providers that look at images
*/
images?: (string | {
/**
* Base64
* @description Base64-encoded image bytes
*/
base64: string;
/**
* Content Type
* @description An image/* media type, such as image/png
*/
content_type: string;
})[] | null;
/**
* Model
* @description A decision model from the proxy model_list
@ -80417,6 +80433,22 @@ export interface operations {
*/
content_type: string;
})[];
/**
* Images
* @description Images as data URLs or content_type plus base64, for providers that look at images
*/
images?: (string | {
/**
* Base64
* @description Base64-encoded image bytes
*/
base64: string;
/**
* Content Type
* @description An image/* media type, such as image/png
*/
content_type: string;
})[] | null;
/**
* Model
* @description A decision model from the proxy model_list