mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix(fal_ai): fix URL construction and param passthrough for generic FAL models
Generic FAL models (nano-banana-2, flux-2/klein, etc.) were getting incorrect URLs (just https://fal.run without model path) and having FAL-specific params (loras, image_urls, guidance_scale, etc.) silently dropped by the supported params filter. This broke cost tracking since all generic FAL requests failed through the proxy. Also adds FAL image edit config and k8s dev ConfigMap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
acd64b9d54
commit
407aba879c
5 changed files with 415 additions and 13 deletions
112
k8s/dev-config.yaml
Normal file
112
k8s/dev-config.yaml
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# LiteLLM Proxy ConfigMap for dev namespace
|
||||
# Apply: kubectl apply -f k8s/dev-config.yaml -n dev
|
||||
# Restart: kubectl rollout restart deployment litellm -n dev
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: litellm-config
|
||||
namespace: dev
|
||||
data:
|
||||
config.yaml: |
|
||||
model_list:
|
||||
# --- FAL AI: Image Generation ---
|
||||
- model_name: fal_ai/flux-2/klein/9b
|
||||
litellm_params:
|
||||
model: fal_ai/flux-2/klein/9b
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/flux-2/klein/9b/base/edit/lora
|
||||
litellm_params:
|
||||
model: fal_ai/flux-2/klein/9b/base/edit/lora
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/nano-banana-2
|
||||
litellm_params:
|
||||
model: fal_ai/nano-banana-2
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/nano-banana-2/edit
|
||||
litellm_params:
|
||||
model: fal_ai/nano-banana-2/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/nano-banana-pro
|
||||
litellm_params:
|
||||
model: fal_ai/nano-banana-pro
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/nano-banana-pro/edit
|
||||
litellm_params:
|
||||
model: fal_ai/nano-banana-pro/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/gemini-3-pro-image-preview
|
||||
litellm_params:
|
||||
model: fal_ai/gemini-3-pro-image-preview
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/gemini-3-pro-image-preview/edit
|
||||
litellm_params:
|
||||
model: fal_ai/gemini-3-pro-image-preview/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/gemini-3.1-flash-image-preview
|
||||
litellm_params:
|
||||
model: fal_ai/gemini-3.1-flash-image-preview
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/gemini-3.1-flash-image-preview/edit
|
||||
litellm_params:
|
||||
model: fal_ai/gemini-3.1-flash-image-preview/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/gemini-25-flash-image
|
||||
litellm_params:
|
||||
model: fal_ai/gemini-25-flash-image
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/gemini-25-flash-image/edit
|
||||
litellm_params:
|
||||
model: fal_ai/gemini-25-flash-image/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/bytedance/seedream/v5/lite/text-to-image
|
||||
litellm_params:
|
||||
model: fal_ai/bytedance/seedream/v5/lite/text-to-image
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/bytedance/seedream/v5/lite/edit
|
||||
litellm_params:
|
||||
model: fal_ai/bytedance/seedream/v5/lite/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/bytedance/seedream/v4.5/text-to-image
|
||||
litellm_params:
|
||||
model: fal_ai/bytedance/seedream/v4.5/text-to-image
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/bytedance/seedream/v4.5/edit
|
||||
litellm_params:
|
||||
model: fal_ai/bytedance/seedream/v4.5/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/bytedance/seedream/v4/text-to-image
|
||||
litellm_params:
|
||||
model: fal_ai/bytedance/seedream/v4/text-to-image
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/bytedance/seedream/v4/edit
|
||||
litellm_params:
|
||||
model: fal_ai/bytedance/seedream/v4/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/flux-pro/kontext
|
||||
litellm_params:
|
||||
model: fal_ai/flux-pro/kontext
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/flux-pro
|
||||
litellm_params:
|
||||
model: fal_ai/flux-pro
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/flux-2/klein/9b/edit
|
||||
litellm_params:
|
||||
model: fal_ai/flux-2/klein/9b/edit
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/kling-image/omni
|
||||
litellm_params:
|
||||
model: fal_ai/kling-image/omni
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
- model_name: fal_ai/iclight-v2
|
||||
litellm_params:
|
||||
model: fal_ai/iclight-v2
|
||||
api_key: os.environ/FAL_AI_API_KEY
|
||||
|
||||
general_settings:
|
||||
master_key: os.environ/LITELLM_MASTER_KEY
|
||||
database_url: os.environ/DATABASE_URL
|
||||
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
telemetry: false
|
||||
19
litellm/llms/fal_ai/image_edit/__init__.py
Normal file
19
litellm/llms/fal_ai/image_edit/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
|
||||
from .transformation import FalAIImageEditConfig
|
||||
|
||||
__all__ = [
|
||||
"FalAIImageEditConfig",
|
||||
"get_fal_ai_image_edit_config",
|
||||
]
|
||||
|
||||
|
||||
def get_fal_ai_image_edit_config(model: str) -> BaseImageEditConfig:
|
||||
"""
|
||||
Get the appropriate Fal AI image edit configuration based on the model.
|
||||
|
||||
Currently all Fal AI edit models use the same generic config since they
|
||||
share the same JSON request/response format. Model-specific configs can
|
||||
be added here in the future if needed.
|
||||
"""
|
||||
return FalAIImageEditConfig()
|
||||
251
litellm/llms/fal_ai/image_edit/transformation.py
Normal file
251
litellm/llms/fal_ai/image_edit/transformation.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"""
|
||||
Fal AI Image Edit Configuration
|
||||
|
||||
Handles transformation between OpenAI-compatible format and Fal AI API format
|
||||
for image editing endpoints (nano-banana-2/edit, gemini-3-pro-image-preview/edit, etc.).
|
||||
|
||||
Fal AI edit endpoints accept JSON with prompt + image_url fields and return
|
||||
{"images": [{"url": "..."}]} synchronously via fal.run — no polling required.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import httpx
|
||||
from httpx._types import RequestFiles
|
||||
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import FileTypes, ImageObject, ImageResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "https://fal.run"
|
||||
|
||||
|
||||
class FalAIImageEditConfig(BaseImageEditConfig):
|
||||
"""
|
||||
Configuration for Fal AI image editing.
|
||||
|
||||
Supports any Fal AI model that accepts edit-style requests
|
||||
(prompt + image_url in JSON body). URL is constructed dynamically
|
||||
from the model name: fal_ai/{path} -> https://fal.run/fal-ai/{path}
|
||||
|
||||
HTTP requests are handled by the generic llm_http_handler (no polling needed).
|
||||
This class only handles data transformation.
|
||||
"""
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> List[str]:
|
||||
return [
|
||||
"n",
|
||||
"size",
|
||||
"response_format",
|
||||
]
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
image_edit_optional_params: ImageEditOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> Dict:
|
||||
optional_params: Dict[str, Any] = {}
|
||||
params_dict = dict(image_edit_optional_params)
|
||||
|
||||
if params_dict.get("n") is not None:
|
||||
optional_params["num_images"] = params_dict["n"]
|
||||
|
||||
if params_dict.get("size") is not None:
|
||||
optional_params["image_size"] = self._map_image_size(params_dict["size"])
|
||||
|
||||
return optional_params
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
model: str,
|
||||
api_key: Optional[str] = None,
|
||||
) -> dict:
|
||||
final_api_key: Optional[str] = api_key or get_secret_str("FAL_AI_API_KEY")
|
||||
if not final_api_key:
|
||||
raise ValueError("FAL_AI_API_KEY is not set")
|
||||
|
||||
headers["Authorization"] = f"Key {final_api_key}"
|
||||
headers["Content-Type"] = "application/json"
|
||||
headers["Accept"] = "application/json"
|
||||
return headers
|
||||
|
||||
def use_multipart_form_data(self) -> bool:
|
||||
return False
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
model: str,
|
||||
api_base: Optional[str],
|
||||
litellm_params: dict,
|
||||
) -> str:
|
||||
base_url: str = (
|
||||
api_base or get_secret_str("FAL_AI_API_BASE") or DEFAULT_BASE_URL
|
||||
)
|
||||
base_url = base_url.rstrip("/")
|
||||
# model arrives without provider prefix (e.g. "nano-banana-2/edit")
|
||||
# FAL API expects fal-ai/ prefix in the URL path
|
||||
return f"{base_url}/fal-ai/{model}"
|
||||
|
||||
def _read_image_bytes(
|
||||
self,
|
||||
image: Any,
|
||||
depth: int = 0,
|
||||
max_depth: int = DEFAULT_MAX_RECURSE_DEPTH,
|
||||
) -> bytes:
|
||||
"""Read image bytes from various input types."""
|
||||
if depth > max_depth:
|
||||
raise ValueError(
|
||||
f"Max recursion depth {max_depth} reached while reading image bytes."
|
||||
)
|
||||
if isinstance(image, bytes):
|
||||
return image
|
||||
elif isinstance(image, list):
|
||||
return self._read_image_bytes(
|
||||
image[0], depth=depth + 1, max_depth=max_depth
|
||||
)
|
||||
elif isinstance(image, str):
|
||||
if image.startswith(("http://", "https://")):
|
||||
response = httpx.get(image, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
else:
|
||||
with open(image, "rb") as f:
|
||||
return f.read()
|
||||
elif hasattr(image, "read"):
|
||||
pos = getattr(image, "tell", lambda: 0)()
|
||||
if hasattr(image, "seek"):
|
||||
image.seek(0)
|
||||
data = image.read()
|
||||
if hasattr(image, "seek"):
|
||||
image.seek(pos)
|
||||
return data
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported image type: {type(image)}. "
|
||||
"Expected bytes, str (URL or file path), or file-like object."
|
||||
)
|
||||
|
||||
def transform_image_edit_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: Optional[str],
|
||||
image: Optional[FileTypes],
|
||||
image_edit_optional_request_params: Dict,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> Tuple[Dict, RequestFiles]:
|
||||
"""
|
||||
Transform OpenAI-style request to Fal AI request format.
|
||||
|
||||
Fal AI edit endpoints accept JSON with image_url (data URI) + prompt.
|
||||
"""
|
||||
request_body: Dict[str, Any] = {}
|
||||
|
||||
if prompt is not None:
|
||||
request_body["prompt"] = prompt
|
||||
|
||||
# Encode the input image as a data URI for Fal AI
|
||||
if image is not None:
|
||||
image_bytes = self._read_image_bytes(image)
|
||||
b64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
data_uri = f"data:image/png;base64,{b64_image}"
|
||||
request_body["image_url"] = data_uri
|
||||
|
||||
# Pass through mapped optional params
|
||||
for key, value in image_edit_optional_request_params.items():
|
||||
if key not in ("extra_headers",) and value is not None:
|
||||
request_body[key] = value
|
||||
|
||||
# Fal AI uses JSON, not multipart
|
||||
return request_body, []
|
||||
|
||||
def transform_image_edit_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ImageResponse:
|
||||
"""
|
||||
Transform Fal AI response to OpenAI-compatible ImageResponse.
|
||||
|
||||
Fal AI returns: {"images": [{"url": "..."}]}
|
||||
"""
|
||||
try:
|
||||
response_data = raw_response.json()
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Error parsing Fal AI response (status={raw_response.status_code}): {e}"
|
||||
)
|
||||
|
||||
image_objects: List[ImageObject] = []
|
||||
images = response_data.get("images", [])
|
||||
if isinstance(images, list):
|
||||
for img in images:
|
||||
if isinstance(img, dict):
|
||||
image_objects.append(
|
||||
ImageObject(
|
||||
url=img.get("url"),
|
||||
b64_json=img.get("b64_json"),
|
||||
)
|
||||
)
|
||||
elif isinstance(img, str):
|
||||
image_objects.append(ImageObject(url=img))
|
||||
|
||||
# Some Fal models return a single "image" instead of "images"
|
||||
if not image_objects:
|
||||
single_image = response_data.get("image")
|
||||
if isinstance(single_image, dict):
|
||||
image_objects.append(
|
||||
ImageObject(
|
||||
url=single_image.get("url"),
|
||||
b64_json=single_image.get("b64_json"),
|
||||
)
|
||||
)
|
||||
elif isinstance(single_image, str):
|
||||
image_objects.append(ImageObject(url=single_image))
|
||||
|
||||
if not image_objects:
|
||||
raise ValueError(
|
||||
f"No images in Fal AI response: {response_data}"
|
||||
)
|
||||
|
||||
return ImageResponse(
|
||||
created=int(time.time()),
|
||||
data=image_objects,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _map_image_size(size: Any) -> Any:
|
||||
"""Map OpenAI size format (e.g. '1024x1024') to Fal AI image_size."""
|
||||
size_map = {
|
||||
"1024x1024": "square_hd",
|
||||
"512x512": "square",
|
||||
"1792x1024": "landscape_16_9",
|
||||
"1024x1792": "portrait_16_9",
|
||||
"1024x768": "landscape_4_3",
|
||||
"768x1024": "portrait_4_3",
|
||||
}
|
||||
if isinstance(size, str) and size in size_map:
|
||||
return size_map[size]
|
||||
if isinstance(size, str) and "x" in size:
|
||||
try:
|
||||
w, h = size.split("x")
|
||||
return {"width": int(w), "height": int(h)}
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
return size
|
||||
|
|
@ -50,6 +50,10 @@ class FalAIBaseConfig(BaseImageGenerationConfig):
|
|||
complete_url = complete_url.rstrip("/")
|
||||
if self.IMAGE_GENERATION_ENDPOINT:
|
||||
complete_url = f"{complete_url}/{self.IMAGE_GENERATION_ENDPOINT}"
|
||||
else:
|
||||
# Generic models need the model name in the URL path
|
||||
# model arrives without provider prefix (e.g. "nano-banana-2/edit")
|
||||
complete_url = f"{complete_url}/fal-ai/{model}"
|
||||
return complete_url
|
||||
|
||||
def validate_environment(
|
||||
|
|
@ -128,12 +132,31 @@ class FalAIImageGenerationConfig(FalAIBaseConfig):
|
|||
self, model: str
|
||||
) -> List[OpenAIImageGenerationOptionalParams]:
|
||||
"""
|
||||
Get supported OpenAI parameters for fal.ai image generation
|
||||
Get supported OpenAI parameters for fal.ai image generation.
|
||||
|
||||
FAL models accept many model-specific params (loras, guidance_scale, etc.)
|
||||
that vary by model. We list known params explicitly and pass through all
|
||||
others in map_openai_params to avoid silently dropping them.
|
||||
"""
|
||||
return [
|
||||
"n",
|
||||
"response_format",
|
||||
"size",
|
||||
# FAL-specific params used across models
|
||||
"image_url",
|
||||
"image_urls",
|
||||
"loras",
|
||||
"num_inference_steps",
|
||||
"guidance_scale",
|
||||
"output_format",
|
||||
"image_size",
|
||||
"aspect_ratio",
|
||||
"enable_safety_checker",
|
||||
"seed",
|
||||
"strength",
|
||||
"num_images",
|
||||
"expand_prompt",
|
||||
"safety_tolerance",
|
||||
]
|
||||
|
||||
def map_openai_params(
|
||||
|
|
@ -143,18 +166,11 @@ class FalAIImageGenerationConfig(FalAIBaseConfig):
|
|||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict:
|
||||
supported_params = self.get_supported_openai_params(model)
|
||||
for k in non_default_params.keys():
|
||||
if k not in optional_params.keys():
|
||||
if k in supported_params:
|
||||
optional_params[k] = non_default_params[k]
|
||||
elif drop_params:
|
||||
pass
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Parameter {k} is not supported for model {model}. Supported parameters are {supported_params}. Set drop_params=True to drop unsupported parameters."
|
||||
)
|
||||
|
||||
"""Pass through all params for FAL. FAL models accept diverse model-specific
|
||||
params that cannot be enumerated ahead of time."""
|
||||
for k, v in non_default_params.items():
|
||||
if k not in optional_params:
|
||||
optional_params[k] = v
|
||||
return optional_params
|
||||
|
||||
def transform_image_generation_request(
|
||||
|
|
|
|||
|
|
@ -9030,6 +9030,10 @@ class ProviderConfigManager:
|
|||
)
|
||||
|
||||
return BlackForestLabsImageEditConfig()
|
||||
elif LlmProviders.FAL_AI == provider:
|
||||
from litellm.llms.fal_ai.image_edit import get_fal_ai_image_edit_config
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue