mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
feat(fal_ai): add flux-lora-depth image edits and moondream3 chat completions (#42334)
* feat(fal_ai): add flux-lora-depth image edits and moondream3 chat completions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(fal_ai): retrigger codecov processing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(fal_ai): reject multi-turn and system messages for moondream3 chat Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(fal_ai): return 400 for invalid moondream3 chat requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(fal_ai): reject moondream3 responses missing output or usage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(fal_ai): reject streaming moondream3 requests before dispatch stream never reaches optional_params, so the transform_request check could not fire; reject in _complete_fal_ai on ctx.stream Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: kerry <kerry@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
5dc6261ebb
commit
b833e1fc4c
17 changed files with 1064 additions and 6 deletions
|
|
@ -2146,6 +2146,10 @@ if TYPE_CHECKING:
|
|||
from .llms.edenai.videos.transformation import (
|
||||
EdenAIVideoConfig as EdenAIVideoConfig,
|
||||
)
|
||||
from .llms.fal_ai.chat.transformation import (
|
||||
FalAIChatConfig as FalAIChatConfig,
|
||||
FalAIError as FalAIError,
|
||||
)
|
||||
from .llms.ovhcloud.chat.transformation import (
|
||||
OVHCloudChatConfig as OVHCloudChatConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -335,6 +335,8 @@ LLM_CONFIG_NAMES: Final = (
|
|||
"EdenAITextToSpeechConfig",
|
||||
"EdenAIImageGenerationConfig",
|
||||
"EdenAIVideoConfig",
|
||||
"FalAIChatConfig",
|
||||
"FalAIError",
|
||||
"OVHCloudChatConfig",
|
||||
"OVHCloudEmbeddingConfig",
|
||||
"CometAPIEmbeddingConfig",
|
||||
|
|
@ -1251,6 +1253,8 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
|
|||
"EdenAITextToSpeechConfig": (".llms.edenai.text_to_speech.transformation", "EdenAITextToSpeechConfig"),
|
||||
"EdenAIImageGenerationConfig": (".llms.edenai.image_generation.transformation", "EdenAIImageGenerationConfig"),
|
||||
"EdenAIVideoConfig": (".llms.edenai.videos.transformation", "EdenAIVideoConfig"),
|
||||
"FalAIChatConfig": (".llms.fal_ai.chat.transformation", "FalAIChatConfig"),
|
||||
"FalAIError": (".llms.fal_ai.chat.transformation", "FalAIError"),
|
||||
"OVHCloudChatConfig": (".llms.ovhcloud.chat.transformation", "OVHCloudChatConfig"),
|
||||
"OVHCloudEmbeddingConfig": (
|
||||
".llms.ovhcloud.embedding.transformation",
|
||||
|
|
|
|||
|
|
@ -859,6 +859,9 @@ def _get_openai_compatible_provider_info(
|
|||
elif custom_llm_provider == "edenai":
|
||||
api_base = litellm.EdenAIChatConfig.get_api_base(api_base) # rebind-ok: chain resolves in place
|
||||
dynamic_api_key = litellm.EdenAIChatConfig.get_api_key(api_key) # rebind-ok: chain resolves in place
|
||||
elif custom_llm_provider == "fal_ai":
|
||||
api_base = litellm.FalAIChatConfig.get_api_base(api_base) # rebind-ok: chain resolves in place
|
||||
dynamic_api_key = litellm.FalAIChatConfig.get_api_key(api_key) # rebind-ok: chain resolves in place
|
||||
elif custom_llm_provider == "aiml":
|
||||
(
|
||||
api_base,
|
||||
|
|
|
|||
3
litellm/llms/fal_ai/chat/__init__.py
Normal file
3
litellm/llms/fal_ai/chat/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .transformation import FalAIChatConfig, FalAIError
|
||||
|
||||
__all__ = ("FalAIChatConfig", "FalAIError")
|
||||
244
litellm/llms/fal_ai/chat/transformation.py
Normal file
244
litellm/llms/fal_ai/chat/transformation.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""
|
||||
Support for `/v1/chat/completions` on Fal AI model endpoints, e.g. fal-ai/moondream3-preview/query.
|
||||
|
||||
These endpoints are not OpenAI-compatible: the request body is a flat ``{"prompt", "image_url"}``
|
||||
object and the response is ``{"output", "reasoning", "finish_reason", "usage_info"}``.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
||||
|
||||
from litellm.litellm_core_utils.core_helpers import map_finish_reason
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import Message, ModelResponse, Usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tiktoken
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
DEFAULT_BASE_URL: Final[str] = "https://fal.run"
|
||||
PROVIDER_PREFIX: Final[str] = "fal_ai/"
|
||||
PASSTHROUGH_PARAMS: Final[frozenset[str]] = frozenset(("reasoning", "temperature", "top_p"))
|
||||
REASONING_DISABLED_EFFORTS: Final[frozenset[str]] = frozenset(("none", "minimal"))
|
||||
REASONING_ENABLED_EFFORTS: Final[frozenset[str]] = frozenset(("low", "medium", "high"))
|
||||
|
||||
|
||||
class _FalUsage(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
|
||||
|
||||
class _FalChatResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
output: str
|
||||
usage_info: _FalUsage
|
||||
reasoning: str | None = None
|
||||
finish_reason: str | None = None
|
||||
|
||||
|
||||
_CHAT_RESPONSE: Final = TypeAdapter(_FalChatResponse)
|
||||
|
||||
|
||||
class FalAIError(BaseLLMException):
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
message: str,
|
||||
headers: dict | httpx.Headers | None = None, # mutable-ok: BaseLLMException header contract
|
||||
) -> None:
|
||||
super().__init__(status_code=status_code, message=message, headers=headers)
|
||||
|
||||
|
||||
def _image_part_url(part: Mapping[str, object]) -> str | None:
|
||||
image_url: Final = part.get("image_url")
|
||||
if isinstance(image_url, str):
|
||||
return image_url
|
||||
if isinstance(image_url, Mapping):
|
||||
url: Final = image_url.get("url")
|
||||
return url if isinstance(url, str) else None
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_and_image(messages: Sequence[AllMessageValues]) -> tuple[str, str]:
|
||||
if len(messages) != 1 or messages[0].get("role") != "user":
|
||||
raise FalAIError(
|
||||
status_code=400,
|
||||
message="fal_ai chat completions accept exactly one user message; system prompts and multi-turn history are not supported",
|
||||
)
|
||||
content: Final = messages[0].get("content")
|
||||
if isinstance(content, str):
|
||||
if not content:
|
||||
raise FalAIError(status_code=400, message="fal_ai chat completions require text in the user message")
|
||||
raise FalAIError(
|
||||
status_code=400,
|
||||
message="fal_ai chat completions require exactly one image_url content part in the user message",
|
||||
)
|
||||
parts: Final[tuple[Mapping[str, object], ...]] = (
|
||||
tuple(part for part in content if isinstance(part, Mapping)) if isinstance(content, Sequence) else ()
|
||||
)
|
||||
prompt: Final = "\n".join(
|
||||
text for part in parts if part.get("type") == "text" and isinstance((text := part.get("text")), str) and text
|
||||
)
|
||||
image_urls: Final = tuple(
|
||||
url for part in parts if part.get("type") == "image_url" and (url := _image_part_url(part)) is not None
|
||||
)
|
||||
if not prompt:
|
||||
raise FalAIError(status_code=400, message="fal_ai chat completions require text in the user message")
|
||||
if len(image_urls) != 1:
|
||||
raise FalAIError(
|
||||
status_code=400,
|
||||
message="fal_ai chat completions require exactly one image_url content part in the user message",
|
||||
)
|
||||
return prompt, image_urls[0]
|
||||
|
||||
|
||||
class FalAIChatConfig(BaseConfig):
|
||||
@staticmethod
|
||||
def get_api_key(api_key: str | None = None) -> str | None:
|
||||
return api_key or get_secret_str("FAL_AI_API_KEY")
|
||||
|
||||
@staticmethod
|
||||
def get_api_base(api_base: str | None = None) -> str:
|
||||
return (api_base or get_secret_str("FAL_AI_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list: # mutable-ok: inherited contract returns a list
|
||||
return list(("reasoning_effort", "temperature", "top_p")) # mutable-ok: inherited contract returns a list
|
||||
|
||||
def _map_reasoning_effort(self, value: object, model: str, drop_params: bool) -> bool | None:
|
||||
if value in REASONING_DISABLED_EFFORTS:
|
||||
return False
|
||||
if value in REASONING_ENABLED_EFFORTS:
|
||||
return True
|
||||
if drop_params:
|
||||
return None
|
||||
raise FalAIError(status_code=400, message=f"Unsupported reasoning_effort '{value}' for {model}")
|
||||
|
||||
def _translate_param(self, param: str, value: object, model: str, drop_params: bool) -> tuple[str, object] | None:
|
||||
if param in ("temperature", "top_p"):
|
||||
return param, value
|
||||
if param == "reasoning_effort":
|
||||
reasoning: Final = self._map_reasoning_effort(value, model, drop_params)
|
||||
return ("reasoning", reasoning) if reasoning is not None else None
|
||||
return None
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: dict, # mutable-ok: inherited contract
|
||||
optional_params: dict, # mutable-ok: inherited contract
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict: # mutable-ok: inherited contract returns a dict
|
||||
mapped: Final = { # mutable-ok: intermediate translation map, folded into the returned dict
|
||||
translated[0]: translated[1]
|
||||
for param, value in non_default_params.items()
|
||||
if (translated := self._translate_param(param, value, model, drop_params)) is not None
|
||||
}
|
||||
return {**optional_params, **mapped} # mutable-ok: inherited contract returns a dict
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict, # mutable-ok: inherited contract
|
||||
model: str,
|
||||
messages: list[AllMessageValues], # mutable-ok: inherited contract
|
||||
optional_params: dict, # mutable-ok: inherited contract
|
||||
litellm_params: dict, # mutable-ok: inherited contract
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict: # mutable-ok: inherited contract returns a dict
|
||||
final_api_key: Final = self.get_api_key(api_key)
|
||||
if not final_api_key:
|
||||
raise ValueError("FAL_AI_API_KEY is not set")
|
||||
return { # mutable-ok: inherited contract returns a dict
|
||||
"content-type": "application/json",
|
||||
**(headers or {}), # mutable-ok: empty default for the inherited contract's headers
|
||||
"Authorization": f"Key {final_api_key}",
|
||||
}
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
optional_params: dict, # mutable-ok: inherited contract
|
||||
litellm_params: dict, # mutable-ok: inherited contract
|
||||
stream: bool | None = None,
|
||||
) -> str:
|
||||
return f"{self.get_api_base(api_base)}/{model.removeprefix(PROVIDER_PREFIX)}"
|
||||
|
||||
def transform_request(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[AllMessageValues], # mutable-ok: inherited contract
|
||||
optional_params: dict, # mutable-ok: inherited contract
|
||||
litellm_params: dict, # mutable-ok: inherited contract
|
||||
headers: dict, # mutable-ok: inherited contract
|
||||
) -> dict: # mutable-ok: inherited contract returns a dict
|
||||
if optional_params.get("stream"):
|
||||
raise FalAIError(status_code=400, message="fal_ai chat completions do not support streaming")
|
||||
prompt, image_url = _prompt_and_image(messages)
|
||||
return { # mutable-ok: JSON request body
|
||||
"prompt": prompt,
|
||||
"image_url": image_url,
|
||||
**{ # mutable-ok: JSON request body
|
||||
key: value for key, value in optional_params.items() if key in PASSTHROUGH_PARAMS and value is not None
|
||||
},
|
||||
}
|
||||
|
||||
def transform_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
model_response: ModelResponse,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
request_data: dict, # mutable-ok: inherited contract
|
||||
messages: list[AllMessageValues], # mutable-ok: inherited contract
|
||||
optional_params: dict, # mutable-ok: inherited contract
|
||||
litellm_params: dict, # mutable-ok: inherited contract
|
||||
encoding: "tiktoken.Encoding | None",
|
||||
api_key: str | None = None,
|
||||
json_mode: bool | None = None,
|
||||
) -> ModelResponse:
|
||||
try:
|
||||
completion_response: Final = _CHAT_RESPONSE.validate_json(raw_response.content)
|
||||
except ValueError:
|
||||
raise FalAIError(
|
||||
status_code=422,
|
||||
message=f"fal_ai returned an unexpected response body: {raw_response.text}",
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
message: Final = Message(
|
||||
content=completion_response.output,
|
||||
role="assistant",
|
||||
reasoning_content=completion_response.reasoning,
|
||||
)
|
||||
model_response.choices[0].message = message # rebind-ok: ModelResponse populated in place per contract
|
||||
model_response.choices[0].finish_reason = map_finish_reason( # rebind-ok: same contract
|
||||
completion_response.finish_reason or "stop"
|
||||
)
|
||||
model_response.created = int(time.time()) # rebind-ok: same contract
|
||||
model_response.model = model # rebind-ok: same contract
|
||||
model_response.usage = Usage( # rebind-ok: same contract
|
||||
prompt_tokens=completion_response.usage_info.input_tokens,
|
||||
completion_tokens=completion_response.usage_info.output_tokens,
|
||||
total_tokens=completion_response.usage_info.input_tokens + completion_response.usage_info.output_tokens,
|
||||
)
|
||||
return model_response
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict | httpx.Headers, # mutable-ok: inherited contract
|
||||
) -> BaseLLMException:
|
||||
return FalAIError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
|
@ -1,3 +1,24 @@
|
|||
from typing import Final
|
||||
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
|
||||
from .flux_lora_depth_transformation import FalAIFluxLoraDepthEditConfig
|
||||
from .transformation import FalAIImageEditConfig
|
||||
|
||||
__all__ = ("FalAIImageEditConfig",)
|
||||
__all__ = ("FalAIFluxLoraDepthEditConfig", "FalAIImageEditConfig")
|
||||
|
||||
|
||||
def get_fal_ai_image_edit_config(model: str) -> BaseImageEditConfig:
|
||||
"""
|
||||
Get the appropriate Fal AI image edit configuration based on the model.
|
||||
|
||||
Args:
|
||||
model: The Fal AI model name (e.g., "openai/gpt-image-2.5/flare/edit", "fal-ai/flux-lora-depth")
|
||||
|
||||
Returns:
|
||||
The appropriate configuration class for the specified model
|
||||
"""
|
||||
model_lower: Final = model.lower()
|
||||
if "flux-lora-depth" in model_lower:
|
||||
return FalAIFluxLoraDepthEditConfig()
|
||||
return FalAIImageEditConfig()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from httpx._types import RequestFiles
|
||||
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import FileTypes
|
||||
|
||||
from .transformation import DEFAULT_BASE_URL, FalAIImageEditConfig, to_data_url
|
||||
|
||||
FLUX_LORA_DEPTH_ENDPOINT: Final[str] = "fal-ai/flux-lora-depth"
|
||||
SUPPORTED_OPENAI_PARAMS: Final[tuple[str, ...]] = ("n", "size")
|
||||
PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType({"n": "num_images", "size": "image_size"})
|
||||
|
||||
|
||||
class FalAIFluxLoraDepthEditConfig(FalAIImageEditConfig):
|
||||
"""
|
||||
FLUX.1 [dev] depth LoRA edit endpoint served through Fal AI.
|
||||
|
||||
Unlike the openai gpt-image ``/edit`` endpoints, this endpoint takes a single ``image_url``
|
||||
control image and has no ``/edit`` path suffix.
|
||||
"""
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list: # mutable-ok: base class contract returns a list
|
||||
return list(SUPPORTED_OPENAI_PARAMS) # mutable-ok: base class contract returns a list
|
||||
|
||||
def map_openai_params( # mutable-ok: base class contract returns a dict
|
||||
self,
|
||||
image_edit_optional_params: ImageEditOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict: # mutable-ok: base class contract returns a dict
|
||||
return { # mutable-ok: base class contract returns a dict
|
||||
PARAM_TRANSLATION.get(key, key): self._translate_value(key, value, model)
|
||||
for key, value in image_edit_optional_params.items()
|
||||
if value is not None and key in PARAM_TRANSLATION
|
||||
}
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
model: str,
|
||||
api_base: str | None,
|
||||
litellm_params: dict, # mutable-ok: base class contract
|
||||
) -> str:
|
||||
base_url: Final = (api_base or get_secret_str("FAL_AI_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
|
||||
return f"{base_url}/{FLUX_LORA_DEPTH_ENDPOINT}"
|
||||
|
||||
def transform_image_edit_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str | None,
|
||||
image: FileTypes | None,
|
||||
image_edit_optional_request_params: dict, # mutable-ok: base class contract
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict, # mutable-ok: base class contract
|
||||
) -> tuple[dict, RequestFiles]: # mutable-ok: base class contract returns a dict
|
||||
images: Final = tuple(img for img in (image if isinstance(image, list) else (image,)) if img is not None)
|
||||
if not images:
|
||||
raise ValueError("Fal AI image edit requires at least one input image")
|
||||
if len(images) > 1:
|
||||
raise ValueError(f"{FLUX_LORA_DEPTH_ENDPOINT} accepts exactly one control image")
|
||||
provider_params: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{
|
||||
key: value for key, value in image_edit_optional_request_params.items() if key != "mask"
|
||||
} # mutable-ok: frozen by MappingProxyType
|
||||
)
|
||||
request_body: Final[dict[str, object]] = { # mutable-ok: base class contract returns a dict
|
||||
"prompt": prompt,
|
||||
"image_url": to_data_url(next(iter(images))),
|
||||
**provider_params,
|
||||
}
|
||||
return request_body, ()
|
||||
|
|
@ -61,7 +61,7 @@ def _read_image_bytes(image: object) -> bytes:
|
|||
raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
|
||||
|
||||
|
||||
def _to_data_url(image: object) -> str:
|
||||
def to_data_url(image: object) -> str:
|
||||
if isinstance(image, str):
|
||||
return image
|
||||
image_bytes: Final = _read_image_bytes(image)
|
||||
|
|
@ -143,7 +143,7 @@ class FalAIImageEditConfig(BaseImageEditConfig):
|
|||
raise ValueError("Fal AI image edit requires at least one input image")
|
||||
mask: Final = _first(image_edit_optional_request_params.get("mask"))
|
||||
mask_field: Final[Mapping[str, str]] = (
|
||||
MappingProxyType({"mask_url": _to_data_url(mask)}) if mask is not None else MappingProxyType({})
|
||||
MappingProxyType({"mask_url": to_data_url(mask)}) if mask is not None else MappingProxyType({})
|
||||
)
|
||||
provider_params: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{
|
||||
|
|
@ -152,7 +152,7 @@ class FalAIImageEditConfig(BaseImageEditConfig):
|
|||
)
|
||||
request_body: Final[dict[str, object]] = { # mutable-ok: base class contract returns a dict
|
||||
"prompt": prompt,
|
||||
"image_urls": tuple(_to_data_url(img) for img in images),
|
||||
"image_urls": tuple(to_data_url(img) for img in images),
|
||||
**mask_field,
|
||||
**provider_params,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3594,6 +3594,37 @@ def _complete_edenai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResu
|
|||
return response
|
||||
|
||||
|
||||
def _complete_fal_ai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
|
||||
if ctx.stream:
|
||||
raise litellm.FalAIError(
|
||||
status_code=400,
|
||||
message="fal_ai chat completions do not support streaming",
|
||||
)
|
||||
api_base: Final = litellm.FalAIChatConfig.get_api_base(ctx.api_base)
|
||||
api_key: Final = litellm.FalAIChatConfig.get_api_key(ctx.api_key or litellm.api_key)
|
||||
response: Final = base_llm_http_handler.completion(
|
||||
model=ctx.model,
|
||||
messages=ctx.messages,
|
||||
api_base=api_base,
|
||||
custom_llm_provider="fal_ai",
|
||||
model_response=ctx.model_response,
|
||||
encoding=_get_encoding(),
|
||||
logging_obj=ctx.logging,
|
||||
optional_params=ctx.optional_params,
|
||||
timeout=ctx.timeout,
|
||||
litellm_params=ctx.litellm_params,
|
||||
shared_session=ctx.shared_session,
|
||||
acompletion=ctx.acompletion,
|
||||
stream=ctx.stream,
|
||||
api_key=api_key,
|
||||
headers=ctx.headers or litellm.headers,
|
||||
client=_dispatch_client_http(ctx),
|
||||
provider_config=ctx.provider_config,
|
||||
)
|
||||
ctx.logging.post_call(input=ctx.messages, api_key=api_key, original_response=response)
|
||||
return response
|
||||
|
||||
|
||||
def _complete_vertex_ai_beta(
|
||||
ctx: _CompletionDispatchContext,
|
||||
) -> _CompletionDispatchResult:
|
||||
|
|
@ -5799,6 +5830,8 @@ def completion(
|
|||
response = _complete_hosted_vllm(_dispatch_ctx)
|
||||
elif custom_llm_provider == "edenai":
|
||||
response = _complete_edenai(_dispatch_ctx) # rebind-ok: dispatch chain binds response per branch
|
||||
elif custom_llm_provider == "fal_ai":
|
||||
response = _complete_fal_ai(_dispatch_ctx) # rebind-ok: dispatch chain binds response per branch
|
||||
elif (
|
||||
# A known OpenAI model name only decides the route when nothing else
|
||||
# resolved a provider. get_llm_provider() already maps these names to
|
||||
|
|
|
|||
|
|
@ -24966,6 +24966,31 @@
|
|||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"fal_ai/fal-ai/flux-lora-depth": {
|
||||
"litellm_provider": "fal_ai",
|
||||
"metadata": {
|
||||
"notes": "fal bills fal-ai/flux-lora-depth at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"
|
||||
},
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.035,
|
||||
"output_cost_per_pixel": 3.337860107421875e-08,
|
||||
"source": "https://fal.ai/models/fal-ai/flux-lora-depth",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"fal_ai/fal-ai/moondream3-preview/query": {
|
||||
"input_cost_per_token": 4e-07,
|
||||
"litellm_provider": "fal_ai",
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.5e-06,
|
||||
"source": "https://fal.ai/models/fal-ai/moondream3-preview/query",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions"
|
||||
],
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"featherless_ai/featherless-ai/Qwerky-72B": {
|
||||
"litellm_provider": "featherless_ai",
|
||||
"max_input_tokens": 32768,
|
||||
|
|
|
|||
|
|
@ -8340,6 +8340,7 @@ class ProviderConfigManager:
|
|||
False,
|
||||
),
|
||||
LlmProviders.EDENAI: (litellm.EdenAIChatConfig, False),
|
||||
LlmProviders.FAL_AI: (litellm.FalAIChatConfig, False),
|
||||
LlmProviders.COMETAPI: (lambda: litellm.CometAPIConfig(), False),
|
||||
LlmProviders.DATAROBOT: (lambda: litellm.DataRobotConfig(), False),
|
||||
LlmProviders.GEMINI: (lambda: litellm.GoogleAIStudioGeminiConfig(), False),
|
||||
|
|
@ -9569,9 +9570,9 @@ class ProviderConfigManager:
|
|||
|
||||
return BlackForestLabsImageEditConfig()
|
||||
elif LlmProviders.FAL_AI == provider:
|
||||
from litellm.llms.fal_ai.image_edit import FalAIImageEditConfig
|
||||
from litellm.llms.fal_ai.image_edit import get_fal_ai_image_edit_config
|
||||
|
||||
return FalAIImageEditConfig()
|
||||
return get_fal_ai_image_edit_config(model)
|
||||
elif LlmProviders.AZURE_AI == provider:
|
||||
from litellm.llms.azure_ai.image_edit import get_azure_ai_image_edit_config
|
||||
|
||||
|
|
|
|||
|
|
@ -24966,6 +24966,31 @@
|
|||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"fal_ai/fal-ai/flux-lora-depth": {
|
||||
"litellm_provider": "fal_ai",
|
||||
"metadata": {
|
||||
"notes": "fal bills fal-ai/flux-lora-depth at $0.035 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price prices the default 1 MP output like the sibling flux entries"
|
||||
},
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.035,
|
||||
"output_cost_per_pixel": 3.337860107421875e-08,
|
||||
"source": "https://fal.ai/models/fal-ai/flux-lora-depth",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"fal_ai/fal-ai/moondream3-preview/query": {
|
||||
"input_cost_per_token": 4e-07,
|
||||
"litellm_provider": "fal_ai",
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.5e-06,
|
||||
"source": "https://fal.ai/models/fal-ai/moondream3-preview/query",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions"
|
||||
],
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"featherless_ai/featherless-ai/Qwerky-72B": {
|
||||
"litellm_provider": "featherless_ai",
|
||||
"max_input_tokens": 32768,
|
||||
|
|
|
|||
|
|
@ -178,6 +178,12 @@
|
|||
"tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_edit_inlines_upload_as_data_url_and_charges_keyed_row": [
|
||||
"other.provider_wire.fal_ai.image_edit_json_data_urls_and_keyed_pricing"
|
||||
],
|
||||
"tests/integration/providers/test_fal_ai_image_wire.py::test_fal_flux_lora_depth_edit_sends_single_image_url_and_charges_flat_row": [
|
||||
"other.provider_wire.fal_ai.flux_lora_depth_edit_single_image_url_and_flat_pricing"
|
||||
],
|
||||
"tests/integration/providers/test_fal_ai_chat_wire.py::test_fal_moondream3_chat_sends_prompt_image_and_reasoning": [
|
||||
"other.provider_wire.fal_ai.moondream3_chat_query_wire_and_token_pricing"
|
||||
],
|
||||
"tests/integration/providers/test_fal_ai_video_wire.py::test_fal_h3_video_create_uses_canonical_body_and_status_path": [
|
||||
"other.provider_wire.fal_ai.video_queue_create_status_and_content_download"
|
||||
],
|
||||
|
|
|
|||
99
tests/integration/providers/test_fal_ai_chat_wire.py
Normal file
99
tests/integration/providers/test_fal_ai_chat_wire.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
_MODEL: Final = "fal-ai/moondream3-preview/query"
|
||||
_PROMPT: Final = "what is in this image?"
|
||||
_COST_MAP_PATH: Final = Path(__file__).resolve().parents[3] / "model_prices_and_context_window.json"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_COST_MAP: Final = TypeAdapter(dict[str, dict[str, object]])
|
||||
|
||||
|
||||
def _catalog_cost(key: str, field: str) -> float:
|
||||
cost_map: Final = _COST_MAP.validate_json(_COST_MAP_PATH.read_bytes())
|
||||
cost_value: Final = cost_map[key][field]
|
||||
assert isinstance(cost_value, (int, float))
|
||||
return float(cost_value)
|
||||
|
||||
|
||||
def _approx(value: float) -> object:
|
||||
return pytest.approx(value, rel=1e-6) # pyright: ignore[reportUnknownMemberType] # pytest lacks typed approx stubs
|
||||
|
||||
|
||||
@pytest.mark.covers("other.provider_wire.fal_ai.moondream3_chat_query_wire_and_token_pricing")
|
||||
def test_fal_moondream3_chat_sends_prompt_image_and_reasoning(gateway: Gateway) -> None:
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST"
|
||||
assert request.headers["authorization"] == "Key synthetic-fal-key"
|
||||
assert request.target == f"/{_MODEL}"
|
||||
assert request.headers["content-type"] == "application/json"
|
||||
assert _JSON_OBJECT.validate_json(request.body) == {
|
||||
"prompt": _PROMPT,
|
||||
"image_url": "https://example.com/pic.png",
|
||||
"reasoning": False,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"output": "a red circle on a blue background",
|
||||
"reasoning": "inspected the shapes",
|
||||
"finish_reason": "stop",
|
||||
"usage_info": {
|
||||
"input_tokens": 11,
|
||||
"output_tokens": 7,
|
||||
"prefill_time_ms": 1.0,
|
||||
"decode_time_ms": 2.0,
|
||||
"ttft_ms": 1.5,
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
wire_url: Final = wire.url
|
||||
model: Final = scenario.model(model=f"fal_ai/{_MODEL}", api_base=wire.url, api_key="synthetic-fal-key")
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": _PROMPT},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/pic.png"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
"reasoning_effort": "none",
|
||||
"temperature": 0.2,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload: Final = _JSON_OBJECT.validate_json(response.content)
|
||||
assert payload["choices"] == [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "a red circle on a blue background",
|
||||
"reasoning_content": "inspected the shapes",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert payload["usage"] == {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}
|
||||
cost: Final = float(response.headers["x-litellm-response-cost"])
|
||||
assert cost == _approx(
|
||||
11 * _catalog_cost(f"fal_ai/{_MODEL}", "input_cost_per_token")
|
||||
+ 7 * _catalog_cost(f"fal_ai/{_MODEL}", "output_cost_per_token")
|
||||
)
|
||||
assert [(request.method, request.target) for request in wire.drain()] == [("POST", f"/{_MODEL}")]
|
||||
|
|
@ -205,3 +205,44 @@ def test_fal_gpt_image_25_edit_inlines_upload_as_data_url_and_charges_keyed_row(
|
|||
assert [(request.method, request.target) for request in wire.drain()] == [
|
||||
("POST", "/openai/gpt-image-2.5/flare/edit")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.covers("other.provider_wire.fal_ai.flux_lora_depth_edit_single_image_url_and_flat_pricing")
|
||||
def test_fal_flux_lora_depth_edit_sends_single_image_url_and_charges_flat_row(gateway: Gateway) -> None:
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST"
|
||||
assert request.headers["authorization"] == "Key synthetic-fal-key"
|
||||
assert request.target == "/fal-ai/flux-lora-depth"
|
||||
assert request.headers["content-type"] == "application/json"
|
||||
assert _JSON_OBJECT.validate_json(request.body) == {
|
||||
"prompt": _PROMPT,
|
||||
"image_url": "data:image/png;base64," + base64.b64encode(_PNG_BYTES).decode(),
|
||||
}
|
||||
return Reply(body=_image_response(((f"{wire_url}/files/depth.png", 1024, 1024),), _PROMPT))
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
wire_url: Final = wire.url
|
||||
model: Final = scenario.model(
|
||||
model="fal_ai/fal-ai/flux-lora-depth", api_base=wire.url, api_key="synthetic-fal-key"
|
||||
)
|
||||
response: Final = gateway.client.post(
|
||||
"/v1/images/edits",
|
||||
data={"model": model, "prompt": _PROMPT},
|
||||
files={"image": ("red_circle.png", _PNG_BYTES, "image/png")},
|
||||
headers={"Authorization": f"Bearer {gateway.key}"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload: Final = _JSON_OBJECT.validate_json(response.content)
|
||||
assert payload["data"] == [
|
||||
{
|
||||
"url": f"{wire.url}/files/depth.png",
|
||||
"b64_json": None,
|
||||
"revised_prompt": None,
|
||||
"provider_specific_fields": {"width": 1024, "height": 1024, "content_type": "image/png"},
|
||||
}
|
||||
]
|
||||
cost: Final = _response_cost(response)
|
||||
assert cost == _approx(_catalog_cost("fal_ai/fal-ai/flux-lora-depth"))
|
||||
assert [(request.method, request.target) for request in wire.drain()] == [
|
||||
("POST", "/fal-ai/flux-lora-depth")
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.fal_ai.chat.transformation import FalAIChatConfig, FalAIError
|
||||
from litellm.types.utils import LlmProviders, ModelResponse
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
MODEL = "fal-ai/moondream3-preview/query"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _use_local_model_cost_map(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
litellm.get_model_info.cache_clear()
|
||||
yield
|
||||
litellm.get_model_info.cache_clear()
|
||||
|
||||
|
||||
def _messages(*content):
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": text} for text in content[:1]]
|
||||
+ [{"type": "image_url", "image_url": {"url": c}} for c in content[1:]],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_config_manager_resolves_fal_ai_chat_config():
|
||||
config = ProviderConfigManager.get_provider_chat_config(model=MODEL, provider=LlmProviders.FAL_AI)
|
||||
assert isinstance(config, FalAIChatConfig)
|
||||
|
||||
|
||||
def test_get_complete_url_targets_fal_endpoint():
|
||||
assert (
|
||||
FalAIChatConfig().get_complete_url(
|
||||
api_base=None, api_key=None, model=MODEL, optional_params={}, litellm_params={}
|
||||
)
|
||||
== "https://fal.run/fal-ai/moondream3-preview/query"
|
||||
)
|
||||
|
||||
|
||||
def test_get_complete_url_strips_fal_ai_model_prefix():
|
||||
assert (
|
||||
FalAIChatConfig().get_complete_url(
|
||||
api_base=None, api_key=None, model=f"fal_ai/{MODEL}", optional_params={}, litellm_params={}
|
||||
)
|
||||
== "https://fal.run/fal-ai/moondream3-preview/query"
|
||||
)
|
||||
|
||||
|
||||
def test_validate_environment_uses_fal_key_scheme():
|
||||
headers = FalAIChatConfig().validate_environment(
|
||||
headers={}, model=MODEL, messages=[], optional_params={}, litellm_params={}, api_key="secret"
|
||||
)
|
||||
assert headers["Authorization"] == "Key secret"
|
||||
|
||||
|
||||
def test_transform_request_joins_text_parts_and_extracts_image_url():
|
||||
body = FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "what is"},
|
||||
{"type": "text", "text": "in this image?"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/pic.png"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
optional_params={"temperature": 0.2, "top_p": 0.9, "reasoning": False},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assert body == {
|
||||
"prompt": "what is\nin this image?",
|
||||
"image_url": "https://example.com/pic.png",
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.9,
|
||||
"reasoning": False,
|
||||
}
|
||||
|
||||
|
||||
def test_transform_request_passes_data_url_through():
|
||||
body = FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "describe"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assert body["image_url"] == "data:image/png;base64,AAAA"
|
||||
|
||||
|
||||
def test_transform_request_accepts_single_user_message():
|
||||
body = FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "describe"}, {"type": "image_url", "image_url": "https://a"}],
|
||||
}
|
||||
],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assert body["prompt"] == "describe"
|
||||
assert body["image_url"] == "https://a"
|
||||
|
||||
|
||||
def test_transform_request_rejects_system_message():
|
||||
with pytest.raises(FalAIError, match="exactly one user message") as exc_info:
|
||||
FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": "be terse"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "describe"}, {"type": "image_url", "image_url": "https://a"}],
|
||||
},
|
||||
],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_transform_request_rejects_multi_turn_history():
|
||||
with pytest.raises(FalAIError, match="exactly one user message") as exc_info:
|
||||
FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "first"}, {"type": "image_url", "image_url": "https://a"}],
|
||||
},
|
||||
{"role": "assistant", "content": "an answer"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "second"}, {"type": "image_url", "image_url": "https://b"}],
|
||||
},
|
||||
],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_transform_request_rejects_zero_images():
|
||||
with pytest.raises(FalAIError, match="exactly one image_url"):
|
||||
FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "describe"}]}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
def test_transform_request_rejects_two_images():
|
||||
with pytest.raises(FalAIError, match="exactly one image_url"):
|
||||
FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "compare"},
|
||||
{"type": "image_url", "image_url": {"url": "https://a"}},
|
||||
{"type": "image_url", "image_url": {"url": "https://b"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
def test_transform_request_rejects_missing_text():
|
||||
with pytest.raises(FalAIError, match="require text"):
|
||||
FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "https://a"}}]}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
def test_transform_request_rejects_streaming():
|
||||
with pytest.raises(FalAIError, match="streaming"):
|
||||
FalAIChatConfig().transform_request(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "describe"},
|
||||
{"type": "image_url", "image_url": {"url": "https://a"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
optional_params={"stream": True},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
def test_completion_dispatch_rejects_streaming():
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
litellm.completion(
|
||||
model=MODEL,
|
||||
custom_llm_provider="fal_ai",
|
||||
stream=True,
|
||||
messages=[{"role": "user", "content": "describe"}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"effort,expected",
|
||||
[("none", False), ("minimal", False), ("low", True), ("medium", True), ("high", True)],
|
||||
)
|
||||
def test_map_openai_params_maps_reasoning_effort(effort, expected):
|
||||
mapped = FalAIChatConfig().map_openai_params(
|
||||
non_default_params={"reasoning_effort": effort}, optional_params={}, model=MODEL, drop_params=False
|
||||
)
|
||||
assert mapped["reasoning"] is expected
|
||||
|
||||
|
||||
def test_map_openai_params_drops_unknown_reasoning_effort_when_dropping():
|
||||
mapped = FalAIChatConfig().map_openai_params(
|
||||
non_default_params={"reasoning_effort": "extreme"}, optional_params={}, model=MODEL, drop_params=True
|
||||
)
|
||||
assert "reasoning" not in mapped
|
||||
|
||||
|
||||
def test_map_openai_params_maps_sampling_params():
|
||||
mapped = FalAIChatConfig().map_openai_params(
|
||||
non_default_params={"temperature": 0.5, "top_p": 0.7, "max_tokens": 10},
|
||||
optional_params={},
|
||||
model=MODEL,
|
||||
drop_params=False,
|
||||
)
|
||||
assert mapped == {"temperature": 0.5, "top_p": 0.7}
|
||||
|
||||
|
||||
def test_transform_response_maps_output_reasoning_usage_and_finish_reason():
|
||||
raw = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"output": "a red circle",
|
||||
"reasoning": "looked at shapes",
|
||||
"finish_reason": "stop",
|
||||
"usage_info": {
|
||||
"input_tokens": 11,
|
||||
"output_tokens": 4,
|
||||
"prefill_time_ms": 1.0,
|
||||
"decode_time_ms": 2.0,
|
||||
"ttft_ms": 1.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
response = FalAIChatConfig().transform_response(
|
||||
model=MODEL,
|
||||
raw_response=raw,
|
||||
model_response=ModelResponse(),
|
||||
logging_obj=None,
|
||||
request_data={},
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
assert response.choices[0].message.content == "a red circle"
|
||||
assert response.choices[0].message.reasoning_content == "looked at shapes"
|
||||
assert response.choices[0].finish_reason == "stop"
|
||||
assert response.usage.prompt_tokens == 11
|
||||
assert response.usage.completion_tokens == 4
|
||||
assert response.usage.total_tokens == 15
|
||||
assert response.model == MODEL
|
||||
|
||||
|
||||
def test_transform_response_omits_reasoning_when_null():
|
||||
raw = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"output": "a red circle",
|
||||
"reasoning": None,
|
||||
"finish_reason": "stop",
|
||||
"usage_info": {"input_tokens": 3, "output_tokens": 2},
|
||||
},
|
||||
)
|
||||
response = FalAIChatConfig().transform_response(
|
||||
model=MODEL,
|
||||
raw_response=raw,
|
||||
model_response=ModelResponse(),
|
||||
logging_obj=None,
|
||||
request_data={},
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
assert response.choices[0].message.content == "a red circle"
|
||||
assert getattr(response.choices[0].message, "reasoning_content", None) is None
|
||||
assert response.usage.total_tokens == 5
|
||||
|
||||
|
||||
def test_transform_response_rejects_body_missing_output():
|
||||
raw = httpx.Response(
|
||||
200,
|
||||
json={"reasoning": "looked", "usage_info": {"input_tokens": 3, "output_tokens": 2}},
|
||||
)
|
||||
with pytest.raises(FalAIError) as exc_info:
|
||||
FalAIChatConfig().transform_response(
|
||||
model=MODEL,
|
||||
raw_response=raw,
|
||||
model_response=ModelResponse(),
|
||||
logging_obj=None,
|
||||
request_data={},
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
|
||||
def test_transform_response_rejects_body_missing_usage_info():
|
||||
raw = httpx.Response(200, json={"output": "a red circle"})
|
||||
with pytest.raises(FalAIError) as exc_info:
|
||||
FalAIChatConfig().transform_response(
|
||||
model=MODEL,
|
||||
raw_response=raw,
|
||||
model_response=ModelResponse(),
|
||||
logging_obj=None,
|
||||
request_data={},
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
assert exc_info.value.status_code == 422
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import base64
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils
|
||||
from litellm.llms.fal_ai.image_edit import (
|
||||
FalAIFluxLoraDepthEditConfig,
|
||||
FalAIImageEditConfig,
|
||||
get_fal_ai_image_edit_config,
|
||||
)
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import ImageObject, ImageResponse, LlmProviders
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||
MODEL = "fal-ai/flux-lora-depth"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _use_local_model_cost_map(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
litellm.get_model_info.cache_clear()
|
||||
yield
|
||||
litellm.get_model_info.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["fal-ai/flux-lora-depth", "flux-lora-depth", "fal_ai/fal-ai/flux-lora-depth"])
|
||||
def test_dispatch_selects_flux_lora_depth_config(model):
|
||||
assert isinstance(get_fal_ai_image_edit_config(model), FalAIFluxLoraDepthEditConfig)
|
||||
|
||||
|
||||
def test_dispatch_keeps_gpt_image_config_for_openai_edit_models():
|
||||
config = get_fal_ai_image_edit_config("openai/gpt-image-2.5/flare/edit")
|
||||
assert type(config) is FalAIImageEditConfig
|
||||
|
||||
|
||||
def test_provider_config_manager_resolves_flux_lora_depth():
|
||||
config = ProviderConfigManager.get_provider_image_edit_config(model=MODEL, provider=LlmProviders.FAL_AI)
|
||||
assert isinstance(config, FalAIFluxLoraDepthEditConfig)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["fal-ai/flux-lora-depth", "flux-lora-depth"])
|
||||
def test_get_complete_url_targets_endpoint_without_edit_suffix(model):
|
||||
url = FalAIFluxLoraDepthEditConfig().get_complete_url(model=model, api_base=None, litellm_params={})
|
||||
assert url == "https://fal.run/fal-ai/flux-lora-depth"
|
||||
|
||||
|
||||
def test_get_supported_openai_params_excludes_quality_mask_background():
|
||||
params = FalAIFluxLoraDepthEditConfig().get_supported_openai_params(model=MODEL)
|
||||
assert "quality" not in params
|
||||
assert "mask" not in params
|
||||
assert "background" not in params
|
||||
|
||||
|
||||
def test_map_openai_params_translates_n_and_size():
|
||||
mapped = FalAIFluxLoraDepthEditConfig().map_openai_params(
|
||||
image_edit_optional_params=ImageEditOptionalRequestParams(n=2, size="1024x1536", quality="high"),
|
||||
model=MODEL,
|
||||
drop_params=False,
|
||||
)
|
||||
assert mapped == {"num_images": 2, "image_size": {"width": 1024, "height": 1536}}
|
||||
|
||||
|
||||
def test_transform_request_sends_single_image_url_as_data_url():
|
||||
body, files = FalAIFluxLoraDepthEditConfig().transform_image_edit_request(
|
||||
model=MODEL,
|
||||
prompt="follow the depth map",
|
||||
image=io.BytesIO(PNG_BYTES),
|
||||
image_edit_optional_request_params={"num_images": 1},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
assert files == ()
|
||||
assert body["prompt"] == "follow the depth map"
|
||||
assert body["image_url"] == "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
|
||||
assert "image_urls" not in body
|
||||
assert body["num_images"] == 1
|
||||
|
||||
|
||||
def test_transform_request_passes_remote_url_through_untouched():
|
||||
body, _ = FalAIFluxLoraDepthEditConfig().transform_image_edit_request(
|
||||
model=MODEL,
|
||||
prompt="follow the depth map",
|
||||
image="https://example.com/depth.png",
|
||||
image_edit_optional_request_params={},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
assert body["image_url"] == "https://example.com/depth.png"
|
||||
|
||||
|
||||
def test_transform_request_rejects_two_images():
|
||||
with pytest.raises(ValueError, match="exactly one control image"):
|
||||
FalAIFluxLoraDepthEditConfig().transform_image_edit_request(
|
||||
model=MODEL,
|
||||
prompt="follow the depth map",
|
||||
image=["https://example.com/a.png", "https://example.com/b.png"],
|
||||
image_edit_optional_request_params={},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
def test_image_edit_cost_uses_flat_output_cost_per_image():
|
||||
cost = CostCalculatorUtils.route_image_generation_cost_calculator(
|
||||
model=MODEL,
|
||||
completion_response=ImageResponse(data=[ImageObject(url="https://example.com/out.png")]),
|
||||
custom_llm_provider="fal_ai",
|
||||
optional_params={},
|
||||
call_type="aimage_edit",
|
||||
)
|
||||
assert cost == litellm.model_cost[f"fal_ai/{MODEL}"]["output_cost_per_image"] > 0
|
||||
Loading…
Add table
Reference in a new issue