feat(image-gen): Kling Image v3 in the FAL catalog (t2i + singular-key i2i)

Adds fal-ai/kling-image/v3/text-to-image ($0.028/img, native 2K default,
8 aspect ratios) with its image-to-image edit endpoint. The i2i schema
takes a SINGULAR `image_url` string instead of the usual `image_urls`
list, so the catalog gains an `edit_image_param` knob that
_build_fal_edit_payload honors (first source image only); the
edit-contract test now validates whichever image key the entry declares.
This commit is contained in:
Teknium
2026-08-22 22:24:21 -07:00
parent 3304d205be
commit ae596d805b
4 changed files with 58 additions and 8 deletions
+5 -2
View File
@@ -87,8 +87,11 @@ class TestFalCatalog:
if "edit_endpoint" not in meta:
continue
assert meta.get("edit_supports"), f"{mid} has edit_endpoint but no edit_supports"
assert "image_urls" in meta["edit_supports"], \
f"{mid} edit_supports must allow image_urls"
# Most edit endpoints take an `image_urls` list; entries with a
# singular image key (Kling Image v3) declare edit_image_param.
image_param = meta.get("edit_image_param") or "image_urls"
assert image_param in meta["edit_supports"], \
f"{mid} edit_supports must allow {image_param}"
cap = meta.get("max_reference_images")
assert isinstance(cap, int) and cap > 0, \
f"{mid} needs a positive max_reference_images"
@@ -67,6 +67,34 @@ class TestFalEditPayload:
# while nano-banana-pro is edit-capable
assert FAL_MODELS["fal-ai/nano-banana-pro"].get("edit_endpoint")
def test_singular_edit_image_param_kling_image_v3(self):
"""Kling Image v3's i2i endpoint takes a SINGULAR `image_url` string;
the catalog opts in via edit_image_param and only the first source
image is sent — no `image_urls` list may leak into the payload."""
from tools.image_generation_tool import _build_fal_edit_payload
payload = _build_fal_edit_payload(
"fal-ai/kling-image/v3/text-to-image", "make it winter",
["https://x/a.png", "https://x/b.png"], "landscape",
)
assert payload["prompt"] == "make it winter"
assert payload["image_url"] == "https://x/a.png"
assert "image_urls" not in payload
assert payload.get("aspect_ratio") == "16:9"
assert payload.get("resolution") == "2K"
def test_kling_image_v3_text_payload(self):
"""t2i payload: whitelist keeps prompt/AR/resolution, defaults 2K."""
from tools import image_generation_tool as t
p = t._build_fal_payload(
"fal-ai/kling-image/v3/text-to-image", "a lighthouse", "portrait",
)
assert p["prompt"] == "a lighthouse"
assert p["aspect_ratio"] == "9:16"
assert p["resolution"] == "2K"
assert p["num_images"] == 1
class TestMandatoryKeysSurviveWhitelist:
"""A model whose whitelist forgets the mandatory keys must not produce a
+20 -2
View File
@@ -19,9 +19,10 @@ def _model(
display: str, speed: str, strengths: str, price: str, *, style: str = "image_size_preset",
sizes: Optional[Dict[str, Any]] = None, defaults: Dict[str, Any], supports: set,
edit_endpoint: Optional[str] = None, edit_supports: Optional[set] = None,
max_reference_images: Optional[int] = None,
max_reference_images: Optional[int] = None, edit_image_param: Optional[str] = None,
) -> Dict[str, Any]:
"""Build one catalog entry; edit keys are present only for edit-capable models."""
"""Build one catalog entry; edit keys are present only for edit-capable models. ``edit_image_param``
names the source-image key when the edit endpoint takes a singular ``image_url`` instead of ``image_urls``."""
entry: Dict[str, Any] = {
"display": display, "speed": speed, "strengths": strengths, "price": price,
"size_style": style, "sizes": sizes if sizes is not None else _DEFAULT_SIZES[style],
@@ -31,6 +32,8 @@ def _model(
entry["edit_endpoint"] = edit_endpoint
entry["edit_supports"] = edit_supports
entry["max_reference_images"] = max_reference_images
if edit_image_param:
entry["edit_image_param"] = edit_image_param
return entry
@@ -347,6 +350,21 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
},
max_reference_images=3,
),
# 1K and 2K cost the same ($0.028/img) so 2K is the default. The i2i endpoint takes a SINGULAR
# `image_url` (one reference image), unlike every other FAL edit endpoint's `image_urls` list.
"fal-ai/kling-image/v3/text-to-image": _model(
"Kling Image v3", "~10s", "Kuaishou. Realistic detail, cheap native 2K, wide AR set", "$0.028/image",
style="aspect_ratio",
defaults={"num_images": 1, "output_format": "png", "resolution": "2K"},
supports={
"prompt", "aspect_ratio", "num_images", "output_format", "resolution", "negative_prompt", "sync_mode",
},
edit_endpoint="fal-ai/kling-image/v3/image-to-image",
edit_supports={
"prompt", "image_url", "aspect_ratio", "num_images", "output_format", "resolution", "sync_mode",
},
max_reference_images=1, edit_image_param="image_url",
),
"meta/muse-image/text-to-image": _model(
"Meta Muse Image", "~5s", "Meta. Realism + typography at commodity price", "$0.01/image",
style="aspect_ratio",
+5 -4
View File
@@ -212,7 +212,7 @@ def _build_payload(model_id, prompt, aspect_ratio, seed, overrides, image_urls=N
spec + overrides, filtered to the model whitelist.
Edit endpoints mostly auto-infer size, so the size key is sent only when ``edit_supports``
lists it. ``prompt`` (and ``image_urls`` on edits) survive a whitelist gap: every FAL
lists it. ``prompt`` (and the source-image key on edits) survive a whitelist gap: every FAL
endpoint requires them, so a catalog mistake can't send a broken request.
"""
meta = FAL_MODELS[model_id]
@@ -225,9 +225,10 @@ def _build_payload(model_id, prompt, aspect_ratio, seed, overrides, image_urls=N
payload: Dict[str, Any] = dict(meta.get("defaults", {}))
payload["prompt"] = (prompt or "").strip()
required = {"prompt"}
if edit:
payload["image_urls"] = list(image_urls)
required.add("image_urls")
if edit: # a few edit endpoints (Kling Image v3) take a singular `image_url` string instead of the list
image_param = meta.get("edit_image_param") or "image_urls"
payload[image_param] = list(image_urls)[0] if image_param != "image_urls" else list(image_urls)
required.add(image_param)
size_key = _SIZE_KEY_BY_STYLE.get(meta["size_style"])
if size_key is None and not edit:
raise ValueError(f"Unknown size_style: {meta['size_style']!r}")