From b2e58c049eff738454a3a78e54918709af6abf99 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:03:28 +0000 Subject: [PATCH 1/2] Let each image model carry its own request parameters Image generation could only send the six named size presets, translated per model by a hard-coded list of size dialects. A model that wants something else (exact pixels such as {"width": 1600, "height": 900}, a resolution tier, step counts) couldn't be used that way without a code change. Settings -> AI Services -> Images now has a "Model parameters (JSON)" editor per model. The JSON is merged into the fal request for that model, for every generation path (editor dialog and the assistant's generate_image action). Stored per user alongside the other image settings, so curated and custom models both work and no migration is needed. Merge rules (build_fal_image_body): - If the JSON sets image_size or aspect_ratio it replaces the size the dropdown would send, so the two can never conflict. Other keys such as resolution or seed go out alongside the dropdown size. - prompt, num_images and sync_mode are managed by the app and refused on save (and stripped again at send time): cost accounting and the download path assume exactly one fetchable image. The editor explains itself: example chips (exact pixels, aspect ratio, resolution, quality, PNG), a link to the model's schema on fal.ai, and a short "What can I put here?" note. The Size dropdown in the Generate image dialog is disabled with an explanation when a model's parameters set the size. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MDpGog3zexuU9KNPpP3jEM --- backend/app/routers/images.py | 4 +- backend/app/routers/settings.py | 74 ++++- backend/tests/test_image_params.py | 272 ++++++++++++++++++ frontend/src/api/settings.ts | 7 + frontend/src/components/ImageGenModal.tsx | 23 +- .../components/settings/ImageGenSettings.tsx | 175 ++++++++++- frontend/src/utils/imageParams.test.ts | 125 ++++++++ frontend/src/utils/imageParams.ts | 117 ++++++++ 8 files changed, 788 insertions(+), 9 deletions(-) create mode 100644 backend/tests/test_image_params.py create mode 100644 frontend/src/utils/imageParams.test.ts create mode 100644 frontend/src/utils/imageParams.ts diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py index 54eeb1e..75bba74 100644 --- a/backend/app/routers/images.py +++ b/backend/app/routers/images.py @@ -29,10 +29,10 @@ _record_usage, _require_safe_external_url, allowed_fal_models, + build_fal_image_body, compute_fal_cost, load_fal_api_key, load_fal_config, - resolve_fal_size_params, ) router = APIRouter() @@ -124,7 +124,7 @@ async def generate_image_for_user( resp = await _post_upstream( f"https://fal.run/{model}", headers={"Authorization": f"Key {api_key}", "Content-Type": "application/json"}, - json_body={"prompt": prompt, "num_images": 1, **resolve_fal_size_params(model, image_size)}, + json_body=build_fal_image_body(model, prompt, image_size, cfg["model_params"].get(model)), timeout=_GENERATE_TIMEOUT, provider_label="fal.ai", ) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 54db566..c0d6217 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -2342,7 +2342,7 @@ def get_usage(request: Request, days: int = 30, session: Session = Depends(get_s _FAL_KEY = "fal_api_key" # encrypted; image generation key (fal.run) _FAL_ADMIN_KEY = "fal_admin_api_key" # encrypted; billing/usage/pricing (api.fal.ai/v1) scope -_FAL_CONFIG = "image_gen_config" # plain JSON: default_model / custom_models / image_size +_FAL_CONFIG = "image_gen_config" # plain JSON: default_model / custom_models / image_size / model_params _FAL_PRICE_CACHE = "fal_price_cache" # plain JSON: {fetched_at, prices: {endpoint_id: {...}}} _FAL_USAGE_URL = "https://api.fal.ai/v1/models/usage" @@ -2426,6 +2426,61 @@ def resolve_fal_size_params(model: str, image_size: str) -> Dict[str, str]: return {"image_size": image_size} +# Per-model request parameters: free-text JSON a user attaches to a model id (exact +# pixel sizes, resolution tiers, steps, ...) that is merged into the fal request. +# Keys the app builds itself can't be overridden — `prompt`; `num_images` (cost +# accounting and the download path assume exactly one image); `sync_mode` (returns a +# data URI instead of a fetchable URL). +IMAGE_PROTECTED_PARAM_KEYS = frozenset({"prompt", "num_images", "sync_mode"}) +# Setting either of these replaces the computed size params entirely, so the size +# dropdown and the user's JSON never send conflicting size fields. +_IMAGE_SIZE_PARAM_KEYS = ("image_size", "aspect_ratio") +_MAX_MODEL_PARAMS_BYTES = 4096 + + +def build_fal_image_body( + model: str, + prompt: str, + image_size: str, + model_params: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """The fal.run request body: the named size preset translated for the endpoint, + overlaid with the user's per-model params. Reused by the images router.""" + extra = {k: v for k, v in (model_params or {}).items() if k not in IMAGE_PROTECTED_PARAM_KEYS} + size = {} if any(k in extra for k in _IMAGE_SIZE_PARAM_KEYS) else resolve_fal_size_params(model, image_size) + return {**size, **extra, "prompt": prompt, "num_images": 1} + + +def _normalize_model_params(raw: Any) -> Dict[str, Dict[str, Any]]: + """Stored `model_params` blob -> {model_id: {param: value}}. Drops malformed + entries and protected keys so a hand-edited blob can't break a request.""" + out: Dict[str, Dict[str, Any]] = {} + if not isinstance(raw, dict): + return out + for model_id, params in raw.items(): + if not isinstance(params, dict): + continue + model_id = FAL_MODEL_ID_RENAMES.get(str(model_id), str(model_id)) + clean = {k: v for k, v in params.items() if k not in IMAGE_PROTECTED_PARAM_KEYS} + if clean: + out[model_id] = clean + return out + + +def _validate_model_params(model_id: str, params: Dict[str, Any], allowed: set) -> None: + """Raise a 400 `invalid_model_params` when a save would store unusable params.""" + def bad(message: str) -> None: + raise HTTPException(status_code=400, detail={"code": "invalid_model_params", "message": message}) + + if model_id not in allowed: + bad(f"Model '{model_id}' is not in the configured model list") + reserved = sorted(k for k in params if k in IMAGE_PROTECTED_PARAM_KEYS) + if reserved: + bad(f"These parameters are managed by the app and can't be set: {', '.join(reserved)}") + if len(json.dumps(params).encode("utf-8")) > _MAX_MODEL_PARAMS_BYTES: + bad(f"Parameters are too large (max {_MAX_MODEL_PARAMS_BYTES // 1024} KB)") + + def _upsert_user_setting(session: Session, user_id: str, key: str, serialised_value: str) -> None: """Insert or update a single per-user setting row. Caller commits.""" existing = session.exec( @@ -2631,6 +2686,7 @@ def load_fal_config(session: Session, user_id: str) -> Dict[str, Any]: "default_model": FAL_MODEL_ID_RENAMES.get(default_model, default_model), "custom_models": custom, "image_size": cfg.get("image_size") or DEFAULT_IMAGE_SIZE, + "model_params": _normalize_model_params(cfg.get("model_params")), } @@ -2658,6 +2714,8 @@ def get_image_settings(request: Request, session: Session = Depends(get_session) "custom_models": cfg["custom_models"], "default_model": cfg["default_model"], "image_size": cfg["image_size"], + "model_params": cfg["model_params"], + "reserved_param_keys": sorted(IMAGE_PROTECTED_PARAM_KEYS), } @@ -2670,6 +2728,9 @@ class ImageSettingsUpdate(BaseModel): default_model: Optional[str] = None custom_models: Optional[List[str]] = None image_size: Optional[str] = None + # Per-model patch: {model_id: {param: value}} sets that model's params, null / {} + # removes them, and models not mentioned are left as stored. + model_params: Optional[Dict[str, Optional[Dict[str, Any]]]] = None @router.put("/images") @@ -2695,10 +2756,21 @@ def update_image_settings( cfg["custom_models"] = [m.strip() for m in payload.custom_models if m and m.strip()] if payload.image_size is not None and payload.image_size in FAL_IMAGE_SIZES: cfg["image_size"] = payload.image_size + if payload.model_params: + # Checked against the model list *after* any custom_models change above, so a + # model added in the same request can carry params straight away. + allowed = allowed_fal_models(session, cfg) + for model_id, params in payload.model_params.items(): + if not params: + cfg["model_params"].pop(model_id, None) + continue + _validate_model_params(model_id, params, allowed) + cfg["model_params"][model_id] = params _upsert_user_setting(session, user_id, _FAL_CONFIG, json.dumps({ "default_model": cfg["default_model"], "custom_models": cfg["custom_models"], "image_size": cfg["image_size"], + "model_params": cfg["model_params"], })) session.commit() diff --git a/backend/tests/test_image_params.py b/backend/tests/test_image_params.py new file mode 100644 index 0000000..fa08939 --- /dev/null +++ b/backend/tests/test_image_params.py @@ -0,0 +1,272 @@ +"""Per-model image parameters — the free-text JSON a user attaches to a fal model. + +The merge is what keeps a user's JSON from fighting the size dropdown (two size fields +in one request 422s on most endpoints) or from breaking the one-image-per-request +assumption the cost accounting and download path are built on. The settings round trip +checks the other half: the params persist per model, and bad ones are refused rather +than stored. +""" + +import asyncio +import json +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from sqlmodel import Session, SQLModel, create_engine + +from app.auth import encrypt_api_key +from app.models import ModelCatalogEntry +from app.routers import images +from app.routers.settings import ( + _FAL_CONFIG, + _FAL_KEY, + ImageSettingsUpdate, + _upsert_user_setting, + build_fal_image_body, + get_image_settings, + load_fal_config, + update_image_settings, +) + +FLUX_2_PRO = "fal-ai/flux-2-pro" +NANO_BANANA = "fal-ai/nano-banana-2" +USER = "user-1" + + +# ─── build_fal_image_body ──────────────────────────────────────────────────── + + +def test_no_params_matches_the_plain_preset_body(): + assert build_fal_image_body(FLUX_2_PRO, "a gecko", "landscape_16_9") == { + "prompt": "a gecko", "num_images": 1, "image_size": "landscape_16_9", + } + assert build_fal_image_body(FLUX_2_PRO, "a gecko", "landscape_16_9", {}) == build_fal_image_body( + FLUX_2_PRO, "a gecko", "landscape_16_9", None + ) + + +@pytest.mark.parametrize("model, expected", [ + ("fal-ai/gpt-image-1.5", {"image_size": "1536x1024"}), + ("fal-ai/flux-pro/v1.1-ultra", {"aspect_ratio": "16:9"}), + ("fal-ai/krea/v2/large/text-to-image", {"aspect_ratio": "16:9"}), + (NANO_BANANA, {"aspect_ratio": "16:9"}), +]) +def test_existing_size_dialects_are_untouched_without_params(model, expected): + body = build_fal_image_body(model, "p", "landscape_16_9") + assert body == {"prompt": "p", "num_images": 1, **expected} + + +def test_image_size_object_replaces_the_computed_size(): + body = build_fal_image_body( + FLUX_2_PRO, "p", "landscape_16_9", {"image_size": {"width": 1600, "height": 900}} + ) + assert body["image_size"] == {"width": 1600, "height": 900} + + +def test_aspect_ratio_override_drops_the_computed_image_size(): + body = build_fal_image_body(FLUX_2_PRO, "p", "landscape_16_9", {"aspect_ratio": "21:9"}) + assert "image_size" not in body + assert body["aspect_ratio"] == "21:9" + + +def test_image_size_override_drops_the_computed_aspect_ratio(): + body = build_fal_image_body(NANO_BANANA, "p", "landscape_16_9", {"image_size": "auto"}) + assert "aspect_ratio" not in body + assert body["image_size"] == "auto" + + +def test_non_size_params_merge_alongside_the_computed_size(): + # `resolution` is a separate dimension from aspect_ratio on nano-banana, so the + # dropdown's aspect ratio must still go out. + body = build_fal_image_body(NANO_BANANA, "p", "landscape_16_9", {"resolution": "2K", "seed": 7}) + assert body == { + "prompt": "p", "num_images": 1, "aspect_ratio": "16:9", "resolution": "2K", "seed": 7, + } + + +@pytest.mark.parametrize("key, value", [ + ("prompt", "hijacked"), + ("num_images", 4), + ("sync_mode", True), +]) +def test_protected_keys_cannot_be_overridden(key, value): + body = build_fal_image_body(FLUX_2_PRO, "real prompt", "square", {key: value}) + assert body["prompt"] == "real prompt" + assert body["num_images"] == 1 + assert "sync_mode" not in body + + +def test_build_does_not_mutate_the_stored_params(): + params = {"image_size": {"width": 1024, "height": 1024}, "prompt": "x"} + build_fal_image_body(FLUX_2_PRO, "p", "square", params) + assert params == {"image_size": {"width": 1024, "height": 1024}, "prompt": "x"} + + +# ─── settings round trip ───────────────────────────────────────────────────── + + +@pytest.fixture +def session(): + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}) + SQLModel.metadata.create_all(engine) + with Session(engine) as s: + s.add(ModelCatalogEntry(id="m1", kind="image", model_id=FLUX_2_PRO, label="FLUX.2 [pro]")) + s.add(ModelCatalogEntry(id="m2", kind="image", model_id=NANO_BANANA, label="Nano Banana 2")) + s.commit() + yield s + + +@pytest.fixture +def request_(): + return SimpleNamespace(state=SimpleNamespace(user_id=USER)) + + +def save(session, request_, **fields): + return update_image_settings(ImageSettingsUpdate(**fields), request_, session) + + +def test_params_persist_per_model(session, request_): + px = {"image_size": {"width": 1600, "height": 900}} + out = save(session, request_, model_params={FLUX_2_PRO: px, NANO_BANANA: {"resolution": "2K"}}) + assert out["model_params"] == {FLUX_2_PRO: px, NANO_BANANA: {"resolution": "2K"}} + # A fresh read goes back through the stored blob, not the update's return value. + assert load_fal_config(session, USER)["model_params"][FLUX_2_PRO] == px + + +def test_patch_leaves_other_models_alone(session, request_): + save(session, request_, model_params={FLUX_2_PRO: {"seed": 1}, NANO_BANANA: {"resolution": "2K"}}) + out = save(session, request_, model_params={FLUX_2_PRO: {"seed": 2}}) + assert out["model_params"] == {FLUX_2_PRO: {"seed": 2}, NANO_BANANA: {"resolution": "2K"}} + + +@pytest.mark.parametrize("empty", [None, {}]) +def test_null_or_empty_clears_a_models_params(session, request_, empty): + save(session, request_, model_params={FLUX_2_PRO: {"seed": 1}, NANO_BANANA: {"resolution": "2K"}}) + out = save(session, request_, model_params={FLUX_2_PRO: empty}) + assert out["model_params"] == {NANO_BANANA: {"resolution": "2K"}} + + +def test_saving_other_settings_keeps_params(session, request_): + # The PUT rewrites the whole blob, so a dropped key here would silently lose params + # every time the user changes the default model. + save(session, request_, model_params={FLUX_2_PRO: {"seed": 1}}) + out = save(session, request_, default_model=NANO_BANANA, image_size="square") + assert out["model_params"] == {FLUX_2_PRO: {"seed": 1}} + + +def test_custom_model_can_carry_params_in_the_same_save(session, request_): + out = save( + session, request_, + custom_models=["fal-ai/some-new-model"], + model_params={"fal-ai/some-new-model": {"num_inference_steps": 20}}, + ) + assert out["model_params"] == {"fal-ai/some-new-model": {"num_inference_steps": 20}} + + +def test_removing_a_custom_model_can_clear_its_params_in_the_same_save(session, request_): + save(session, request_, custom_models=["fal-ai/x"], model_params={"fal-ai/x": {"seed": 1}}) + out = save(session, request_, custom_models=[], model_params={"fal-ai/x": None}) + assert out["custom_models"] == [] + assert out["model_params"] == {} + + +@pytest.mark.parametrize("key", ["prompt", "num_images", "sync_mode"]) +def test_protected_key_is_rejected_with_a_message(session, request_, key): + with pytest.raises(HTTPException) as exc: + save(session, request_, model_params={FLUX_2_PRO: {key: 1}}) + assert exc.value.status_code == 400 + assert exc.value.detail["code"] == "invalid_model_params" + assert key in exc.value.detail["message"] + assert load_fal_config(session, USER)["model_params"] == {} + + +def test_unknown_model_is_rejected(session, request_): + with pytest.raises(HTTPException) as exc: + save(session, request_, model_params={"fal-ai/not-configured": {"seed": 1}}) + assert exc.value.status_code == 400 + assert exc.value.detail["code"] == "invalid_model_params" + + +def test_oversized_params_are_rejected(session, request_): + with pytest.raises(HTTPException) as exc: + save(session, request_, model_params={FLUX_2_PRO: {"blob": "x" * 5000}}) + assert exc.value.status_code == 400 + assert "too large" in exc.value.detail["message"] + + +def test_a_rejected_save_stores_nothing_for_the_other_models_in_it(session, request_): + with pytest.raises(HTTPException): + save(session, request_, model_params={NANO_BANANA: {"seed": 1}, FLUX_2_PRO: {"prompt": "x"}}) + assert load_fal_config(session, USER)["model_params"] == {} + + +def test_get_reports_the_reserved_keys_for_the_ui(session, request_): + out = get_image_settings(request_, session) + assert out["reserved_param_keys"] == ["num_images", "prompt", "sync_mode"] + assert out["model_params"] == {} + + +def test_hand_edited_blob_is_normalised_on_load(session, request_): + # A blob written through the generic settings API bypasses the validation above, so + # load must still never hand a protected key or a non-object entry to the request. + _upsert_user_setting(session, USER, _FAL_CONFIG, json.dumps({ + "model_params": { + FLUX_2_PRO: {"prompt": "x", "seed": 3}, + NANO_BANANA: "not-an-object", + "fal-ai/flux-pro/kontext": {"seed": 5}, # a renamed legacy id + }, + })) + session.commit() + params = load_fal_config(session, USER)["model_params"] + assert params == { + FLUX_2_PRO: {"seed": 3}, + "fal-ai/flux-pro/kontext/text-to-image": {"seed": 5}, + } + + +# ─── wiring into the generate call ─────────────────────────────────────────── + + +class _Captured(Exception): + """Raised by the stub in place of fal so the call stops before any download.""" + + def __init__(self, url, json_body): + self.url = url + self.json_body = json_body + + +def _body_sent_for(session, **generate_kwargs): + async def stub(url, *, json_body, **_): + raise _Captured(url, json_body) + + original = images._post_upstream + images._post_upstream = stub + try: + with pytest.raises(_Captured) as sent: + asyncio.run(images.generate_image_for_user(session, USER, "a gecko", **generate_kwargs)) + finally: + images._post_upstream = original + return sent.value + + +def test_generate_sends_the_saved_params_for_that_model(session, request_): + _upsert_user_setting(session, USER, _FAL_KEY, json.dumps(encrypt_api_key("test-key"))) + session.commit() + px = {"image_size": {"width": 1600, "height": 900}, "seed": 11} + save(session, request_, model_params={FLUX_2_PRO: px}) + + sent = _body_sent_for(session, model=FLUX_2_PRO, image_size="square") + + assert sent.url == f"https://fal.run/{FLUX_2_PRO}" + assert sent.json_body == {"prompt": "a gecko", "num_images": 1, **px} + + +def test_generate_leaves_other_models_on_the_preset_path(session, request_): + _upsert_user_setting(session, USER, _FAL_KEY, json.dumps(encrypt_api_key("test-key"))) + session.commit() + save(session, request_, model_params={FLUX_2_PRO: {"seed": 11}}) + + sent = _body_sent_for(session, model=NANO_BANANA, image_size="landscape_16_9") + + assert sent.json_body == {"prompt": "a gecko", "num_images": 1, "aspect_ratio": "16:9"} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index b1c276f..539c236 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -228,6 +228,10 @@ export interface ImageSettings { custom_models: string[] default_model: string image_size: string + // Free-form request params merged into each fal call, keyed by model id. + model_params: Record> + // Request keys the app builds itself; the server refuses them in model_params. + reserved_param_keys: string[] } export interface ImageSettingsUpdate { @@ -236,6 +240,9 @@ export interface ImageSettingsUpdate { default_model?: string custom_models?: string[] image_size?: string + // Per-model patch: an object sets that model's params, null clears them, and + // models not listed are left as stored. + model_params?: Record | null> } export interface FalPrice { diff --git a/frontend/src/components/ImageGenModal.tsx b/frontend/src/components/ImageGenModal.tsx index 63ff4e8..925fea5 100644 --- a/frontend/src/components/ImageGenModal.tsx +++ b/frontend/src/components/ImageGenModal.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { X, Sparkles, Loader2 } from 'lucide-react' import { settingsApi, type ImageSettings, type FalPrice } from '@/api/settings' import { imageGenApi, estimateImageCost, formatCost } from '@/api/imageGen' +import { overridesSize } from '@/utils/imageParams' const IMAGE_SIZE_LABELS: Record = { square_hd: 'Square (HD)', @@ -70,7 +71,10 @@ export default function ImageGenModal({ onInsert, onClose }: Props) { ? [...settings.curated_models, ...settings.custom_models.map((id) => ({ id, label: id }))] : [] const noKey = settings !== null && !settings.has_api_key - const est = estimateImageCost(prices[model], size) + // A model whose saved parameters set the size ignores the dropdown, and the preset-based + // estimate (megapixels per preset) no longer describes what will be generated. + const sizeOverridden = overridesSize(settings?.model_params[model]) + const est = sizeOverridden ? null : estimateImageCost(prices[model], size) return (
@@ -117,11 +121,26 @@ export default function ImageGenModal({ onInsert, onClose }: Props) {
- setSize(e.target.value)} + disabled={sizeOverridden} + > + {sizeOverridden && } {(settings?.image_sizes ?? []).map((s) => ( ))} + {sizeOverridden && ( +

+ Set by this model’s parameters in{' '} + + Settings + + . +

+ )}
diff --git a/frontend/src/components/settings/ImageGenSettings.tsx b/frontend/src/components/settings/ImageGenSettings.tsx index 22ca775..a3079c1 100644 --- a/frontend/src/components/settings/ImageGenSettings.tsx +++ b/frontend/src/components/settings/ImageGenSettings.tsx @@ -1,7 +1,15 @@ import { useEffect, useState } from 'react' -import { Plus, Trash2, Loader2 } from 'lucide-react' +import { Plus, Trash2, Loader2, ExternalLink } from 'lucide-react' import { settingsApi, type ImageSettings, type ImageUsage, type FalPrice } from '@/api/settings' import { estimateImageCost, formatCost } from '@/api/imageGen' +import { + PARAM_EXAMPLES, + falModelDocsUrl, + formatModelParams, + mergeExample, + overridesSize, + parseModelParamsText, +} from '@/utils/imageParams' const IMAGE_SIZE_LABELS: Record = { square_hd: 'Square (HD)', @@ -18,6 +26,12 @@ export default function ImageGenSettings() { const [prices, setPrices] = useState>({}) const [newModel, setNewModel] = useState('') const [error, setError] = useState(null) + // Model-parameters editor: which model it edits (null follows the default model), the + // unsaved textarea draft, and its own error since free text saves on demand, not per change. + const [paramsModel, setParamsModel] = useState(null) + const [paramsText, setParamsText] = useState('') + const [paramsError, setParamsError] = useState(null) + const [paramsSaving, setParamsSaving] = useState(false) async function load() { try { @@ -69,16 +83,61 @@ export default function ImageGenSettings() { const default_model = settings.default_model === id ? settings.curated_models[0]?.id ?? '' : settings.default_model - void patch({ custom_models: custom, default_model }) + // Drop the model's parameters with it so they don't linger if the id is re-added. + void patch({ custom_models: custom, default_model, model_params: { [id]: null } }) } const allModels = settings ? [...settings.curated_models, ...settings.custom_models.map((id) => ({ id, label: id }))] : [] - // Per-image estimate for the current default model + size. + // The model the parameters editor is on: the one picked there, else the default model. + const editModel = settings + ? (allModels.some((m) => m.id === paramsModel) ? (paramsModel as string) : settings.default_model) + : '' + const savedParamsText = formatModelParams(settings?.model_params[editModel]) + const paramsDirty = paramsText !== savedParamsText + // An emptied box over saved params is a clear, so say so on the button. + const clearingParams = !paramsText.trim() && !!savedParamsText + + // Reload the draft when the edited model, or what's saved for it, changes. Keyed on the + // text rather than the settings object so saving an unrelated setting (e.g. the size) + // doesn't wipe a half-typed draft. + useEffect(() => { + setParamsText(savedParamsText) + setParamsError(null) + }, [editModel, savedParamsText]) + + async function saveParams() { + if (!settings) return + const parsed = parseModelParamsText(paramsText, settings.reserved_param_keys) + if (!parsed.ok) { + setParamsError(parsed.error) + return + } + setParamsError(null) + setParamsSaving(true) + try { + setSettings(await settingsApi.updateImageSettings({ model_params: { [editModel]: parsed.value } })) + // The echo only moves the saved text if the value changed; normalise the draft either way. + setParamsText(formatModelParams(parsed.value)) + } catch (e) { + const detail = (e as { response?: { data?: { detail?: { message?: string } | string } } }).response?.data?.detail + setParamsError( + detail && typeof detail === 'object' && detail.message ? detail.message : 'Failed to save parameters', + ) + } finally { + setParamsSaving(false) + } + } + + // The default model's parameters can replace the size the dropdown would send. + const sizeOverridden = settings ? overridesSize(settings.model_params[settings.default_model]) : false + + // Per-image estimate for the current default model + size. The estimate scales by the + // preset's megapixels, so it means nothing once the parameters set the size themselves. const estPrice = settings ? prices[settings.default_model] : undefined - const estCost = settings ? estimateImageCost(estPrice, settings.image_size) : null + const estCost = settings && !sizeOverridden ? estimateImageCost(estPrice, settings.image_size) : null return (
@@ -127,6 +186,11 @@ export default function ImageGenSettings() { ))} + {sizeOverridden && ( +

+ The default model’s parameters set the size themselves, so this is ignored for it. +

+ )}
@@ -159,6 +223,109 @@ export default function ImageGenSettings() {
+ +
+

Advanced

+
+ +

+ Extra settings sent to fal.ai every time this model is used, such as an exact pixel + size, a resolution tier or the number of steps. The keys must match what the model + itself accepts. +

+ +
+ Add: + {PARAM_EXAMPLES.map((ex) => ( + + ))} +
+