feat(media): default-on upscaling for sub-2MP image models (FAL + Krea)

Per review: upscaling should be the default behavior (like the original
flux-2-pro chain), not agent opt-in. Policy: every image model whose
native output is below ~2MP now sets upscale=True in its catalog —
users never silently get low-res images. Native hi-res models
(Seedream 5 Pro/Lite, Krea 2 Large) stay off to avoid paying to
upscale already-large output.

- FAL catalog: 16 models flipped to upscale=True (klein, z-image,
  nano-banana pro/2/2-lite, gpt-image 1.5/2, ideogram v3/v4, recraft
  v4/v4.1, qwen image/3, krea-2 medium on FAL, MAI 2.5 pro).
- Krea plugin: per-model upscale defaults (medium + medium-turbo ON at
  1.5K native; large OFF at 2K native), precedence explicit kwarg >
  image_gen.krea.upscale config > catalog default.
- The 'upscale' tool param remains as a per-call override in both
  directions (false = fast draft, true = force on hi-res/edits).
- Video unchanged: opt-in only (default-on would double every video's
  cost and latency).
- Sibling tests updated: routing/payload tests pass upscale=False where
  the assertion targets the generation submit; catalog test now pins
  the native-resolution policy instead of the flux-2-pro snapshot.
This commit is contained in:
Teknium
2026-08-08 12:12:54 -07:00
parent 137960c9aa
commit 66ea4e686d
6 changed files with 131 additions and 77 deletions

View File

@@ -57,6 +57,9 @@ _MODELS: Dict[str, Dict[str, Any]] = {
"strengths": "Illustration, anime, painting, expressive styles. Faster + cheaper.",
"price": "$0.030 (text) / $0.035 (style refs) / $0.040 (moodboards)",
"path": "medium",
# 1.5K native — default the Enhance pass on (mirrors the FAL
# catalog policy: sub-2MP models upscale by default).
"upscale": True,
},
"krea-2-large": {
"display": "Krea 2 Large",
@@ -64,6 +67,8 @@ _MODELS: Dict[str, Dict[str, Any]] = {
"strengths": "Photorealism, raw textured looks (motion blur, grain), expressive styles.",
"price": "$0.060 (text) / $0.065 (style refs) / $0.070 (moodboards)",
"path": "large",
# 2K native — high-res enough out of the box.
"upscale": False,
},
"krea-2-medium-turbo": {
"display": "Krea 2 Medium Turbo",
@@ -71,6 +76,8 @@ _MODELS: Dict[str, Dict[str, Any]] = {
"strengths": "Fastest Krea 2 — medium quality at lower latency / cost.",
"price": "$0.015 (text) / $0.0175 (style refs)",
"path": "medium-turbo",
# 1.5K native — default the Enhance pass on.
"upscale": True,
},
}
@@ -821,17 +828,20 @@ class KreaImageGenProvider(ImageGenProvider):
aspect_ratio=aspect,
)
# Optional high-resolution pass (Krea Enhance). Explicit agent/user
# opt-in via the ``upscale`` kwarg; config default via
# ``image_gen.krea.upscale``. Best-effort: failure falls back to the
# original image rather than failing the generation.
# High-resolution pass (Krea Enhance). Precedence: explicit kwarg >
# ``image_gen.krea.upscale`` config > per-model catalog default
# (1.5K-native tiers default on; 2K-native Large stays off). Best-
# effort: failure falls back to the original image rather than
# failing the generation.
upscaled = False
upscale_requested = kwargs.get("upscale")
if not isinstance(upscale_requested, bool):
cfg_krea = _load_krea_config().get("krea")
upscale_requested = bool(
isinstance(cfg_krea, dict) and cfg_krea.get("upscale") is True
)
cfg_upscale = cfg_krea.get("upscale") if isinstance(cfg_krea, dict) else None
if isinstance(cfg_upscale, bool):
upscale_requested = cfg_upscale
else:
upscale_requested = bool(meta.get("upscale", False))
if upscale_requested:
enhanced_url = _enhance_image(
base_url,

View File

@@ -163,7 +163,7 @@ class TestGenerate:
return_value=Path("/tmp/krea_krea-2-medium_test.png"),
) as mock_save, \
patch("plugins.image_gen.krea.time.sleep"): # skip real waits
result = KreaImageGenProvider().generate(prompt="A cinematic lamp")
result = KreaImageGenProvider().generate(prompt="A cinematic lamp", upscale=False)
assert result["success"] is True
assert result["image"] == "/tmp/krea_krea-2-medium_test.png"
@@ -215,7 +215,7 @@ class TestGenerate:
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(prompt="test", aspect_ratio="square")
KreaImageGenProvider().generate(prompt="test", aspect_ratio="square", upscale=False)
payload = mock_post.call_args.kwargs["json"]
assert payload["aspect_ratio"] == "1:1"
@@ -260,6 +260,7 @@ class TestGenerate:
moodboards=[{"url": "https://x.com/mood.png"}, {"url": "https://x.com/mood2.png"}],
image_style_references=[{"url": f"https://x.com/{i}.png"} for i in range(15)],
creativity="high",
upscale=False,
)
payload = mock_post.call_args.kwargs["json"]
@@ -290,6 +291,7 @@ class TestGenerate:
"https://x.com/a.png",
{"url": "https://x.com/b.png", "strength": 1.2},
],
upscale=False,
)
payload = mock_post.call_args.kwargs["json"]
@@ -513,7 +515,7 @@ class TestManagedGateway:
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="A managed lamp")
result = KreaImageGenProvider().generate(prompt="A managed lamp", upscale=False)
assert result["success"] is True
post_url = mock_post.call_args[0][0]
@@ -573,7 +575,7 @@ class TestExplicitModelOverride:
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test", model="krea-2-medium-turbo")
result = KreaImageGenProvider().generate(prompt="test", model="krea-2-medium-turbo", upscale=False)
assert result["success"] is True
assert result["model"] == "krea-2-medium-turbo"
@@ -587,7 +589,7 @@ class TestExplicitModelOverride:
class TestUpscalePass:
def _run_generate(self, *, upscale, enhance_job):
def _run_generate(self, *, upscale, enhance_job, model=None):
"""Drive generate() with sequenced post/get mocks.
Sequence: generation submit POST → generation poll GET; then (when
@@ -603,6 +605,10 @@ class TestUpscalePass:
posts = [gen_submit, enh_submit]
gets = [gen_poll] + ([enh_poll] if enh_poll else [])
kwargs = {"prompt": "a lamp", "upscale": upscale}
if model is not None:
kwargs["model"] = model
with patch("plugins.image_gen.krea.requests.post", side_effect=posts) as mock_post, \
patch("plugins.image_gen.krea.requests.get", side_effect=gets) as mock_get, \
patch(
@@ -610,7 +616,7 @@ class TestUpscalePass:
side_effect=lambda url, prefix: Path(f"/tmp/{url.rsplit('/', 1)[-1]}"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="a lamp", upscale=upscale)
result = KreaImageGenProvider().generate(**kwargs)
return result, mock_post, mock_get
def test_upscale_routes_through_enhance_endpoint(self):
@@ -651,14 +657,42 @@ class TestUpscalePass:
assert result["image"].endswith("native.png")
assert mock_post.call_count == 2 # enhance attempted, fell back
def test_no_upscale_by_default(self):
result, mock_post, _ = self._run_generate(upscale=None, enhance_job=None)
def test_medium_upscales_by_default(self):
"""krea-2-medium is 1.5K native — the Enhance pass defaults ON."""
enhance_job = {
"job_id": "00000000-0000-0000-0000-00000000e0e0",
"status": "completed",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"urls": ["https://krea.cdn/enhanced.png"]},
}
result, mock_post, _ = self._run_generate(upscale=None, enhance_job=enhance_job)
assert result["success"] is True
assert result["upscaled"] is True
assert result["image"].endswith("enhanced.png")
assert mock_post.call_count == 2
def test_large_skips_upscale_by_default(self):
"""krea-2-large is 2K native — no automatic Enhance pass."""
result, mock_post, _ = self._run_generate(
upscale=None, enhance_job=None, model="krea-2-large",
)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 1 # only the generation submit
def test_explicit_false_disables_default(self):
"""Explicit upscale=False wins over medium's default-on."""
result, mock_post, _ = self._run_generate(upscale=False, enhance_job=None)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 1
# ---------------------------------------------------------------------------
# Registration

View File

@@ -57,17 +57,23 @@ class TestFalCatalog:
assert not missing, f"{mid} missing required keys: {missing}"
def test_only_flux2_pro_upscales_by_default(self, image_tool):
"""Upscaling should default to False for all new models to preserve
the <1s / fast-render value prop. Only flux-2-pro stays True for
backward-compat with the previous default."""
def test_upscale_defaults_track_native_resolution(self, image_tool):
"""Default-on upscaling: every model whose native output is below
~2MP upscales by default so users never silently get low-res images.
Models that already emit >=2MP natively (Seedream tiers, Krea 2
Large on FAL) skip the pass — upscaling them wastes money."""
native_hi_res = {
"bytedance/seedream/v5/pro/text-to-image", # 1536²-2048² native
"bytedance/seedream/v5/lite/text-to-image", # up to 4K native
"fal-ai/krea/v2/large/text-to-image", # 2K native
}
for mid, meta in image_tool.FAL_MODELS.items():
if mid == "fal-ai/flux-2-pro":
assert meta["upscale"] is True, \
"flux-2-pro should keep upscale=True for backward-compat"
else:
if mid in native_hi_res:
assert meta["upscale"] is False, \
f"{mid} should default to upscale=False"
f"{mid} is native hi-res — should not double-upscale"
else:
assert meta["upscale"] is True, \
f"{mid} should default to upscale=True (sub-2MP native)"
# ---------------------------------------------------------------------------
@@ -494,19 +500,22 @@ class TestUpscaleOptIn:
expected_url = "https://fal/upscaled.png" if upscaler_called else "https://fal/native.png"
assert out["image"] == expected_url
def test_explicit_true_upscales_non_default_model(self, image_tool, monkeypatch):
"""Klein has upscale=False in the catalog — explicit True wins."""
def test_explicit_true_upscales_native_hi_res_model(self, image_tool, monkeypatch):
"""Seedream Lite has upscale=False in the catalog (native 4K) —
explicit True still wins."""
self._run(image_tool, monkeypatch,
model="fal-ai/flux-2/klein/9b", upscale=True, upscaler_called=True)
model="bytedance/seedream/v5/lite/text-to-image",
upscale=True, upscaler_called=True)
def test_explicit_false_disables_flux2_pro_default(self, image_tool, monkeypatch):
"""flux-2-pro defaults to upscale=True — explicit False wins."""
def test_explicit_false_disables_default_on_model(self, image_tool, monkeypatch):
"""Klein defaults to upscale=True (sub-2MP native) — explicit False wins."""
self._run(image_tool, monkeypatch,
model="fal-ai/flux-2-pro", upscale=False, upscaler_called=False)
model="fal-ai/flux-2/klein/9b", upscale=False, upscaler_called=False)
def test_omitted_keeps_catalog_default_off(self, image_tool, monkeypatch):
self._run(image_tool, monkeypatch,
model="fal-ai/flux-2/klein/9b", upscale=None, upscaler_called=False)
model="bytedance/seedream/v5/lite/text-to-image",
upscale=None, upscaler_called=False)
def test_omitted_keeps_catalog_default_on(self, image_tool, monkeypatch):
self._run(image_tool, monkeypatch,

View File

@@ -124,7 +124,11 @@ class TestFalRouting:
capture: dict = {}
self._patch_submit(monkeypatch, image_tool, capture)
raw = image_tool.image_generate_tool(prompt="a cat", aspect_ratio="square")
# Routing test — disable the (default-on) upscale pass so the captured
# endpoint is the generation submit, not the upscaler.
raw = image_tool.image_generate_tool(
prompt="a cat", aspect_ratio="square", upscale=False,
)
out = json.loads(raw)
assert out["success"] is True
assert out["modality"] == "text"

View File

@@ -115,7 +115,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "image_size", "num_inference_steps", "seed",
"output_format", "enable_safety_checker",
},
"upscale": False,
"upscale": True,
# Image-to-image / editing: FLUX.2 [klein] 9B edit endpoint takes
# `image_urls` (list). Natural-language edits, multi-ref.
"edit_endpoint": "fal-ai/flux-2/klein/9b/edit",
@@ -183,7 +183,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"seed", "output_format", "enable_safety_checker",
"enable_prompt_expansion",
},
"upscale": False,
"upscale": True,
},
"fal-ai/nano-banana-pro": {
"display": "Nano Banana Pro (Gemini 3 Pro Image)",
@@ -209,7 +209,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"safety_tolerance", "seed", "sync_mode", "resolution",
"enable_web_search", "limit_generations",
},
"upscale": False,
"upscale": True,
# Nano Banana Pro edit (Gemini 3 Pro Image): natural-language edits
# with up to 2 reference images via `image_urls`.
"edit_endpoint": "fal-ai/nano-banana-pro/edit",
@@ -244,7 +244,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"resolution", "enable_web_search", "limit_generations",
"thinking_level",
},
"upscale": False,
"upscale": True,
"edit_endpoint": "fal-ai/nano-banana-2/edit",
"edit_supports": {
"prompt", "image_urls", "aspect_ratio", "num_images",
@@ -276,7 +276,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "image_size", "quality", "num_images", "output_format",
"background", "sync_mode",
},
"upscale": False,
"upscale": True,
# Edit endpoint: high-fidelity edits preserving composition/lighting.
"edit_endpoint": "fal-ai/gpt-image-1.5/edit",
"edit_supports": {
@@ -315,7 +315,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
# openai_api_key (BYOK) intentionally omitted — all users go
# through the shared FAL billing path.
},
"upscale": False,
"upscale": True,
# GPT Image 2 edit endpoint lives under the OpenAI namespace on FAL
# (NOT fal-ai/). Takes `image_urls` (list) + optional mask. We don't
# send `image_size` on edit so the model auto-infers from input.
@@ -346,7 +346,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "image_size", "rendering_speed", "expand_prompt",
"style", "seed",
},
"upscale": False,
"upscale": True,
# Ideogram V3 edit endpoint takes `image_urls` (list).
"edit_endpoint": "fal-ai/ideogram/v3/edit",
"edit_supports": {
@@ -374,7 +374,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "image_size", "enable_safety_checker",
"colors", "background_color",
},
"upscale": False,
"upscale": True,
},
"fal-ai/qwen-image": {
"display": "Qwen Image",
@@ -398,7 +398,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "image_size", "num_inference_steps", "guidance_scale",
"num_images", "output_format", "acceleration", "seed", "sync_mode",
},
"upscale": False,
"upscale": True,
# Qwen edit uses the Qwen Image 2.0 Pro editing endpoint, which takes
# `image_urls` (list) + natural-language edit instructions.
"edit_endpoint": "fal-ai/qwen-image-2/pro/edit",
@@ -429,7 +429,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "aspect_ratio", "creativity", "seed",
"image_style_references",
},
"upscale": False,
"upscale": True,
},
"fal-ai/krea/v2/large/text-to-image": {
"display": "Krea 2 Large",
@@ -524,7 +524,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "image_size", "expansion_model", "num_images",
"seed", "sync_mode", "enable_safety_checker", "output_format",
},
"upscale": False,
"upscale": True,
},
"ideogram/v4/fast": {
"display": "Ideogram V4 (Fast)",
@@ -545,7 +545,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "image_size", "expansion_model", "rendering_speed",
"num_images", "seed", "sync_mode",
},
"upscale": False,
"upscale": True,
},
"alibaba/qwen-image-3/text-to-image": {
"display": "Qwen Image 3",
@@ -569,7 +569,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"seed", "sync_mode", "output_format",
"enable_prompt_expansion", "enable_safety_checker",
},
"upscale": False,
"upscale": True,
# Qwen Image 3 edit: 1-3 reference images, identity-preserving edits.
"edit_endpoint": "alibaba/qwen-image-3/edit",
"edit_supports": {
@@ -598,7 +598,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "aspect_ratio", "num_images", "output_format",
"sync_mode",
},
"upscale": False,
"upscale": True,
},
"google/nano-banana-2-lite": {
"display": "Nano Banana 2 Lite",
@@ -621,7 +621,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"output_format", "safety_tolerance", "sync_mode",
"system_prompt", "limit_generations", "thinking_level",
},
"upscale": False,
"upscale": True,
# Fast multi-turn local edits with reference images via `image_urls`.
"edit_endpoint": "google/nano-banana-2-lite/edit",
"edit_supports": {
@@ -649,7 +649,7 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
"prompt", "image_size", "enable_safety_checker",
"colors", "background_color",
},
"upscale": False,
"upscale": True,
},
}
@@ -1459,11 +1459,12 @@ IMAGE_GENERATE_SCHEMA = {
"upscale": {
"type": "boolean",
"description": (
"Optional high-resolution pass: when true, the generated "
"image is run through the active backend's upscaler/"
"enhancer (extra cost and latency, roughly 2x resolution). "
"Use when the user asks for high-res / print / wallpaper "
"quality output. Omit for the model's native resolution."
"Optional override for the high-resolution pass. Models "
"with sub-2MP native output upscale automatically (~2x, "
"extra cost/latency); pass false for a faster/cheaper "
"draft at native resolution, or true to force the pass "
"on native hi-res models and image edits. Omit to keep "
"the per-model default."
),
},
},

View File

@@ -160,37 +160,33 @@ This translation happens in `_build_fal_payload()` — agent code never has to k
## Upscaling
### On-demand (any model)
### Automatic (default-on for low-res models)
The agent-facing `upscale` parameter requests a high-resolution pass after
generation on **any** model — ask for "high-res", "print quality", or
"wallpaper" output and the agent sets `upscale: true`:
Every model whose native output is below ~2MP automatically runs a
high-resolution pass after generation, so you never silently get a low-res
image:
| Backend | Upscaler | Result |
| Backend | Models upscaled by default | Upscaler |
|---|---|---|
| **FAL.ai** (all models) | Clarity Upscaler | ~2× resolution, +$0.03/MP |
| **Krea** (Krea 2 family) | Krea Enhance | 2× resolution (up to 8K ceiling) |
| Other backends | — | parameter is ignored (native resolution returned) |
| **FAL.ai** | all except Seedream 5 Pro/Lite and Krea 2 Large (native ≥2MP) | Clarity Upscaler (2×, +$0.03/MP) |
| **Krea** | Krea 2 Medium + Medium Turbo (1.5K native); Large (2K) skips | Krea Enhance (2×, up to 8K ceiling) |
| Other backends | — | no upscaler; native resolution returned |
An explicit `upscale: false` also *disables* the automatic pass on models
that default to it (currently `flux-2-pro`). Passing `upscale: true` with an
image edit runs the pass on the edited output too.
### The `upscale` parameter (per-call override)
`video_generate` accepts the same `upscale` parameter on the FAL backend,
chaining ByteDance's **SeedVR2** video upscaler (2×, $0.001/MP of output
video) after generation.
The agent-facing `upscale` boolean overrides the default in either
direction:
### Automatic (per-model default)
- `upscale: false` — skip the automatic pass (faster/cheaper draft output)
- `upscale: true` — force the pass, even on native hi-res models or image
edits
Upscaling via FAL's **Clarity Upscaler** also runs automatically for models
whose catalog entry sets `upscale: True`:
`video_generate` also accepts `upscale: true` on the FAL backend, chaining
ByteDance's **SeedVR2** video upscaler (2×, $0.001/MP of output video) after
generation. Video stays opt-in — doubling every video's resolution by
default would double its cost and latency.
| Model | Upscale? | Why |
|---|---|---|
| `fal-ai/flux-2-pro` | ✓ | Backward-compat (was the pre-picker default) |
| All others | ✗ | Fast models would lose their sub-second value prop; hi-res models don't need it |
When upscaling runs, it uses these settings:
When the FAL image pass runs, it uses these settings:
| Setting | Value |
|---|---|
@@ -207,7 +203,7 @@ If upscaling fails (network issue, rate limit), the original image is returned a
1. **Model resolution** — `_resolve_fal_model()` reads `image_gen.model` from `config.yaml`, falls back to the `FAL_IMAGE_MODEL` env var, then to `fal-ai/flux-2/klein/9b`.
2. **Payload building** — `_build_fal_payload()` translates your `aspect_ratio` into the model's native format (preset enum, aspect-ratio enum, or GPT literal), merges the model's default params, applies any caller overrides, then filters to the model's `supports` whitelist so unsupported keys are never sent.
3. **Submission** — `_submit_fal_request()` routes via direct FAL credentials or the managed Nous gateway.
4. **Upscaling** — runs when the agent passed `upscale: true`, or when the model's metadata has `upscale: True` (explicit `upscale: false` wins over the metadata default).
4. **Upscaling** — runs when the model's catalog entry has `upscale: True` (the default for sub-2MP models) or the agent passed `upscale: true`; an explicit `upscale: false` always skips it.
5. **Delivery** — final image URL returned to the agent, which emits a `MEDIA:<url>` tag that platform adapters convert to native media.
## Debugging