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:
devin-ai-integration[bot] 2026-09-21 19:01:28 -07:00 • committed by GitHub
parent 5dc6261ebb
commit b833e1fc4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1064 additions and 6 deletions

View file

@ -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,
)

View file

@ -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",

View file

@ -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,

View file

@ -0,0 +1,3 @@
from .transformation import FalAIChatConfig, FalAIError
__all__ = ("FalAIChatConfig", "FalAIError")

View 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)

View file

@ -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()

View file

@ -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, ()

View file

@ -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,
}

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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"
],

View 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}")]

View file

@ -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")
]

View file

@ -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

View file

@ -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