From ae596d805b446007e31bce8514a3be0ba154d132 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:24:21 -0700 Subject: [PATCH] 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. --- tests/tools/test_image_generation.py | 7 +++-- .../test_image_generation_image_to_image.py | 28 +++++++++++++++++++ tools/image_generation_catalog.py | 22 +++++++++++++-- tools/image_generation_tool.py | 9 +++--- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_image_generation.py b/tests/tools/test_image_generation.py index 31ba54029e..bbbbfe8616 100644 --- a/tests/tools/test_image_generation.py +++ b/tests/tools/test_image_generation.py @@ -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" diff --git a/tests/tools/test_image_generation_image_to_image.py b/tests/tools/test_image_generation_image_to_image.py index 5dd3ea7591..b5afc2b460 100644 --- a/tests/tools/test_image_generation_image_to_image.py +++ b/tests/tools/test_image_generation_image_to_image.py @@ -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 diff --git a/tools/image_generation_catalog.py b/tools/image_generation_catalog.py index 08f8a483ec..2364a5521f 100644 --- a/tools/image_generation_catalog.py +++ b/tools/image_generation_catalog.py @@ -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", diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 4476743008..9a1ec2de01 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -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}")