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:
Albert Sebastian 2026-04-14 02:35:34 +05:30
parent acd64b9d54
commit 407aba879c
5 changed files with 415 additions and 13 deletions

112
k8s/dev-config.yaml Normal file
View 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

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

View 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

View file

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

View file

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