diff --git a/backend/alembic/versions/20261102_1200_seed_flux_pro.py b/backend/alembic/versions/20261102_1200_seed_flux_pro.py new file mode 100644 index 0000000..e8fa1e3 --- /dev/null +++ b/backend/alembic/versions/20261102_1200_seed_flux_pro.py @@ -0,0 +1,170 @@ +"""seed the FLUX Pro text-to-image models + +The catalogue offered only FLUX.1 [schnell] and [dev] for text to image, and schnell +sorted first. Video Creator takes the first model of a kind when nobody names one, so +every picture its assistant made came from the draft model. These three rows are the +quality tier, and they sort ahead of schnell (sort_order 5–7 against its 10) so that a +user who has set no default (see `default_generation_models` in app/settings_store.py) +starts from Pro rather than from a draft. + +The rows are a literal copy, not an import, for the reason the first seed migration +(`a8e1c0f4d2b6`) gives. Field names and enums were checked against the endpoint input +types generated from fal's OpenAPI schemas in `@fal-ai/client` 1.11.0-alpha.6 +(`src/types/endpoints.d.ts`, 2026-11-02); the evidence for each row is beside it. + +A row whose endpoint is already in the catalogue (an admin added it by hand) is left +alone, and the downgrade removes only rows this revision inserted and nobody has edited +since, so it never deletes an admin's work. + +Revision ID: b7d41e9c2a60 +Revises: aecf97cd87b1 +Create Date: 2026-11-02 12:00:00.000000+00:00 +""" +import json +import uuid +from datetime import datetime, timezone +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b7d41e9c2a60' +down_revision: Union[str, None] = 'aecf97cd87b1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _options(*, aspect_ratios=(), image_sizes=(), max_outputs=1, custom_size=None) -> str: + return json.dumps( + { + "aspect_ratios": list(aspect_ratios), + "image_sizes": list(image_sizes), + "durations": [], + "resolutions": [], + "supports_seed": True, + "supports_negative_prompt": False, + "supports_audio": False, + "max_outputs": max_outputs, + "custom_size": custom_size, + } + ) + + +_FLUX_SIZES = ( + "square_hd", "square", "portrait_4_3", "portrait_16_9", "landscape_4_3", "landscape_16_9", +) + +SEEDS = [ + dict( + endpoint_id="fal-ai/flux-pro/v1.1-ultra", + label="FLUX1.1 [pro] ultra", + note="Highest quality, up to about 4 megapixels. Sizes by aspect ratio.", + sort_order=5, + # FluxProV11UltraInput: `aspect_ratio` 21:9…9:21 (no presets, no width/height), + # `num_images`, `seed`, plus `raw` and `safety_tolerance`, which an admin can pin + # in extra_params. Its `image_url` is an image *prompt*, not a base to edit, so + # the row stays text to image. + options=_options( + aspect_ratios=("21:9", "16:9", "4:3", "3:2", "1:1", "2:3", "3:4", "9:16", "9:21"), + max_outputs=4, + ), + # Price: fal's published list price, per image at any ratio. Only the fallback: + # the job asks fal's pricing API first. + unit_price=0.06, + price_unit="image", + ), + dict( + endpoint_id="fal-ai/flux-2-pro", + label="FLUX.2 [pro]", + note="Newest FLUX. Follows long prompts closely and renders text well.", + sort_order=6, + # Flux2ProInput (= Flux2MaxInput): `image_size` preset or {width, height}, `seed`, + # `safety_tolerance`. No `num_images`, so one picture a run. + options=_options( + image_sizes=_FLUX_SIZES, + max_outputs=1, + # About 4 megapixels, the most fal documents for FLUX.2. The schema states no + # bounds; these are not checked against fal, which could not be reached when + # this was written. An admin can change them on the row. + custom_size={"max_width": 2048, "max_height": 2048, "multiple_of": 16, + "max_pixels": 4_194_304}, + ), + # Price: fal bills $0.03 for the first megapixel and less for each after, so a + # flat $0.03 a megapixel overstates a large picture a little. + unit_price=0.03, + price_unit="megapixel", + ), + dict( + endpoint_id="fal-ai/flux-pro/v1.1", + label="FLUX1.1 [pro]", + note="Fast, sharp FLUX Pro at up to 1440 pixels a side.", + sort_order=7, + # FluxProV11Input: `image_size` preset or {width, height}, `num_images`, `seed`, + # `safety_tolerance`. + options=_options( + image_sizes=_FLUX_SIZES, + max_outputs=4, + # Black Forest Labs' documented range for FLUX1.1 [pro]: 256–1440 a side in + # steps of 32. Not checked against fal, for the reason above. + custom_size={"max_width": 1440, "max_height": 1440, "multiple_of": 32, + "max_pixels": None}, + ), + # Price: fal's published list price; the fallback, as above. + unit_price=0.04, + price_unit="megapixel", + ), +] + +_COLUMNS = ( + "id", "endpoint_id", "kind", "label", "note", "sort_order", "is_active", "image_field", + "image_field_is_list", "max_images", "end_image_field", "options", "extra_params", + "unit_price", "price_unit", "price_currency", "created_at", "updated_at", +) + + +def upgrade() -> None: + conn = op.get_bind() + existing = { + row[0] + for row in conn.execute(sa.text("SELECT endpoint_id FROM generationmodel")).fetchall() + } + # Naive UTC, as every timestamp here is (app/clock.py), spelled out rather than + # imported for the reason the module docstring gives. + now = datetime.now(timezone.utc).replace(tzinfo=None) + insert = sa.text( + f"INSERT INTO generationmodel ({', '.join(_COLUMNS)}) " + f"VALUES ({', '.join(':' + c for c in _COLUMNS)})" + ) + for seed in SEEDS: + if seed["endpoint_id"] in existing: + continue + conn.execute(insert, { + "id": str(uuid.uuid4()), + "kind": "text_to_image", + "is_active": True, + "image_field": None, + "image_field_is_list": False, + "max_images": 0, + "end_image_field": None, + "extra_params": "{}", + "price_currency": "USD", + "created_at": now, + "updated_at": now, + **seed, + }) + + +def downgrade() -> None: + # Only rows nobody has saved since: an admin's edit bumps updated_at past + # created_at, and a row added by hand before this revision has other options. + conn = op.get_bind() + for seed in SEEDS: + conn.execute( + sa.text( + "DELETE FROM generationmodel WHERE endpoint_id = :endpoint_id " + "AND options = :options AND label = :label AND updated_at = created_at" + ), + {"endpoint_id": seed["endpoint_id"], "options": seed["options"], + "label": seed["label"]}, + ) diff --git a/backend/app/generation/defaults.py b/backend/app/generation/defaults.py new file mode 100644 index 0000000..8c8babb --- /dev/null +++ b/backend/app/generation/defaults.py @@ -0,0 +1,90 @@ +"""Each user's default model per generation kind. + +Kept by fal endpoint id rather than by catalogue row id: an endpoint id means the same +thing on every install and survives an admin deleting and re-adding a row, and it is +what a caller outside GAM (Video Creator's per-project choice) can name without having +read this install's catalogue first. + +A default whose row is gone or switched off reads as no default, never as an error: the +catalogue changes under every user's feet, and a stale preference should fall back to +the catalogue's own order rather than break every form. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlmodel import Session, col, select + +from app.models.generation import GENERATION_KINDS, GenerationModel +from app.settings_store import DEFAULT_GENERATION_MODELS, get_setting, set_setting + + +class InvalidDefault(ValueError): + """A default that cannot be saved. The message says which kind and why.""" + + +def stored(session: Session, user_id: str) -> dict[str, str]: + """The user's defaults as saved, with anything malformed dropped.""" + value = get_setting(session, user_id, DEFAULT_GENERATION_MODELS, {}) + if not isinstance(value, dict): + return {} + return { + kind: endpoint + for kind, endpoint in value.items() + if kind in GENERATION_KINDS and isinstance(endpoint, str) and endpoint + } + + +def update(session: Session, user_id: str, changes: dict[str, Optional[str]]) -> dict[str, str]: + """Apply a PATCH-like change: a kind set to an endpoint id, or to null/"" to clear it. + + Every kind is checked before anything is written, so one bad entry saves nothing. + """ + current = stored(session, user_id) + for kind, endpoint in changes.items(): + if kind not in GENERATION_KINDS: + raise InvalidDefault(f"There is no kind of generation called {kind!r}") + if endpoint is None or (isinstance(endpoint, str) and not endpoint.strip()): + current.pop(kind, None) + continue + if not isinstance(endpoint, str): + raise InvalidDefault(f"The default for {kind} must be a model's endpoint id") + endpoint = endpoint.strip() + row = session.exec( + select(GenerationModel).where(GenerationModel.endpoint_id == endpoint) + ).first() + if row is None or not row.is_active: + raise InvalidDefault(f"{endpoint} is not an active model in the catalogue") + if row.kind != kind: + raise InvalidDefault(f"{row.label} is not a {kind.replace('_', ' ')} model") + current[kind] = endpoint + set_setting(session, user_id, DEFAULT_GENERATION_MODELS, current) + return current + + +def ordered(rows: list[GenerationModel], defaults: dict[str, str]) -> list[GenerationModel]: + """The rows with each kind's default moved to the front of its kind, order otherwise + kept. Moving it, rather than only flagging it, is what makes a caller that takes the + first model of a kind (Video Creator, and anything older than the flag) follow the + user's choice without knowing the setting exists.""" + def key(item: tuple[int, GenerationModel]) -> tuple[Any, ...]: + index, row = item + return (row.kind, 0 if defaults.get(row.kind) == row.endpoint_id else 1, index) + + return [row for _index, row in sorted(enumerate(rows), key=key)] + + +def active_defaults(session: Session, user_id: str) -> dict[str, str]: + """The stored defaults that still name an active row of the right kind.""" + wanted = stored(session, user_id) + if not wanted: + return {} + rows = session.exec( + select(GenerationModel).where( + col(GenerationModel.endpoint_id).in_(list(wanted.values())), + col(GenerationModel.is_active).is_(True), + ) + ).all() + live = {(row.kind, row.endpoint_id) for row in rows} + return {kind: endpoint for kind, endpoint in wanted.items() if (kind, endpoint) in live} diff --git a/backend/app/routers/generate.py b/backend/app/routers/generate.py index 94f8bfb..b6be76e 100644 --- a/backend/app/routers/generate.py +++ b/backend/app/routers/generate.py @@ -23,7 +23,7 @@ from app.auth import AdminUser, CurrentUser, is_admin from app.clock import utcnow from app.database import get_session -from app.generation import catalogue +from app.generation import catalogue, defaults from app.generation.catalogue import InvalidEntry, extra_params_of, options_of from app.generation.request import ( ROLE_BASE, @@ -74,7 +74,7 @@ def _error(status_code: int, code: str, message: str) -> HTTPException: # ─── the catalogue ─────────────────────────────────────────────────────────── -def _read(row: GenerationModel) -> GenerationModelRead: +def _read(row: GenerationModel, *, is_default: bool = False) -> GenerationModelRead: return GenerationModelRead( id=row.id, endpoint_id=row.endpoint_id, @@ -92,6 +92,7 @@ def _read(row: GenerationModel) -> GenerationModelRead: unit_price=row.unit_price, price_unit=row.price_unit, price_currency=row.price_currency, + is_default=is_default, created_at=row.created_at, updated_at=row.updated_at, ) @@ -179,7 +180,8 @@ def list_models( include_inactive: bool = Query(default=False), session: Session = Depends(get_session), ) -> ListResponse[GenerationModelRead]: - """The catalogue, ordered as the form lists it. + """The catalogue, ordered as the form lists it: by kind, the caller's default for + the kind first (flagged `is_default`), then sort order and label. `include_inactive` is for the admin editor; anyone else asking for it gets the active rows, since a switched-off model is not one they can use. @@ -192,7 +194,10 @@ def list_models( col(GenerationModel.kind), col(GenerationModel.sort_order), col(GenerationModel.label) ) ).all() - return ListResponse(data=[_read(r) for r in rows], total=len(rows), limit=len(rows), offset=0) + chosen = defaults.active_defaults(session, user.id) + rows = defaults.ordered(list(rows), chosen) + data = [_read(r, is_default=chosen.get(r.kind) == r.endpoint_id) for r in rows] + return ListResponse(data=data, total=len(rows), limit=len(rows), offset=0) @router.post("/models", response_model=DataResponse[GenerationModelRead], status_code=201) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index c0fa4f9..c2a94ee 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -19,6 +19,7 @@ from app.embeddings.openai import DEFAULT_MODEL as OPENAI_DEFAULT_MODEL from app.enrichment.deepgram import DEFAULT_MODEL from app.safe_url import require_safe_external_url +from app.generation import defaults from app.schemas import DataResponse from app.schemas_generate import GenerationSettings, GenerationSettingsUpdate from app.settings_store import ( @@ -92,8 +93,9 @@ def write_speech_settings( # ─── generation (M8) ───────────────────────────────────────────────────────── # -# Just the key. The model catalogue is global and lives at /api/generate/models; what -# each user brings is the account fal bills. +# The key, and which model each kind starts on. The model catalogue itself is global and +# lives at /api/generate/models; what each user brings is the account fal bills and a +# preference among the rows. @router.get("/generation", response_model=DataResponse[GenerationSettings]) @@ -101,7 +103,10 @@ def read_generation_settings( user: CurrentUser, session: Session = Depends(get_session) ) -> DataResponse[GenerationSettings]: return DataResponse( - data=GenerationSettings(fal_key_configured=has_setting(session, user.id, FAL_API_KEY)) + data=GenerationSettings( + fal_key_configured=has_setting(session, user.id, FAL_API_KEY), + default_models=defaults.active_defaults(session, user.id), + ) ) @@ -116,6 +121,14 @@ def write_generation_settings( set_setting(session, user.id, FAL_API_KEY, payload.fal_api_key.strip()) else: clear_setting(session, user.id, FAL_API_KEY) + if payload.default_models is not None: + try: + defaults.update(session, user.id, payload.default_models) + except defaults.InvalidDefault as exc: + raise HTTPException( + status_code=422, + detail={"code": "invalid_default_model", "message": str(exc)}, + ) from exc return read_generation_settings(user, session) diff --git a/backend/app/schemas_generate.py b/backend/app/schemas_generate.py index 387dfe1..7700752 100644 --- a/backend/app/schemas_generate.py +++ b/backend/app/schemas_generate.py @@ -36,6 +36,9 @@ class GenerationModelRead(BaseModel): unit_price: Optional[float] = None price_unit: Optional[str] = None price_currency: Optional[str] = None + # The caller's own default for this kind (app/generation/defaults.py). Per user, so + # never stored on the row; the list also puts this row first in its kind. + is_default: bool = False created_at: datetime updated_at: datetime @@ -120,9 +123,14 @@ class RegenerateRequest(BaseModel): class GenerationSettings(BaseModel): fal_key_configured: bool + # `{kind: endpoint_id}`, only for kinds with a default that is still usable. + default_models: dict[str, str] = Field(default_factory=dict) class GenerationSettingsUpdate(BaseModel): # None leaves the stored key alone; "" removes it — the contract every credential # in this app follows, since without it a key could never be cleared. fal_api_key: Optional[str] = None + # Only the kinds named change; a kind set to null or "" goes back to the + # catalogue's own order. Any, for the reason the module docstring gives. + default_models: Optional[dict[str, Any]] = None diff --git a/backend/app/settings_store.py b/backend/app/settings_store.py index f0956b6..5039677 100644 --- a/backend/app/settings_store.py +++ b/backend/app/settings_store.py @@ -38,6 +38,9 @@ # second "admin" fal key for billing scope; GAM reads only list prices, which an ordinary # key is documented to reach, and a second credential nobody needs is one more to leak. FAL_API_KEY = "fal_api_key" +# `{kind: endpoint_id}`: the model each generation form, and Video Creator, starts on. +# See app/generation/defaults.py. +DEFAULT_GENERATION_MODELS = "default_generation_models" # Keys whose value is encrypted at rest. Anything not in here is stored as plain JSON. SECRET_KEYS = frozenset({DEEPGRAM_API_KEY, OPENAI_API_KEY, FAL_API_KEY}) diff --git a/backend/tests/test_generation_catalogue.py b/backend/tests/test_generation_catalogue.py index f0e2708..d5606f9 100644 --- a/backend/tests/test_generation_catalogue.py +++ b/backend/tests/test_generation_catalogue.py @@ -364,12 +364,12 @@ def test_an_admin_deletes_a_model(auth_client, admin, models): def test_the_fal_key_is_stored_and_never_returned(auth_client, session): - assert auth_client.get("/api/settings/generation").json() == {"data": {"fal_key_configured": False}} + assert auth_client.get("/api/settings/generation").json() == {"data": {"fal_key_configured": False, "default_models": {}}} response = auth_client.put("/api/settings/generation", json={"fal_api_key": " fal-secret-123 "}) assert response.status_code == 200 - assert response.json() == {"data": {"fal_key_configured": True}} + assert response.json() == {"data": {"fal_key_configured": True, "default_models": {}}} assert "fal-secret-123" not in auth_client.get("/api/settings/generation").text # Encrypted at rest, and decrypts to what was typed, trimmed. row = session.exec( diff --git a/backend/tests/test_generation_defaults.py b/backend/tests/test_generation_defaults.py new file mode 100644 index 0000000..e12d4b9 --- /dev/null +++ b/backend/tests/test_generation_defaults.py @@ -0,0 +1,92 @@ +"""Each user's default model per kind: saved in settings, applied to the catalogue list. + +The list puts the default first in its kind, not only flags it, because Video Creator +(and anything else that takes the first model of a kind) is meant to follow the choice +without knowing the setting exists. +""" + +import pytest + +from app.models.generation import GenerationModel +from tests.fal_stub import I2I, I2V, T2I, VEO, seed_models + + +@pytest.fixture +def models(session): + return seed_models(session) + + +def _order(client, kind): + rows = client.get("/api/generate/models").json()["data"] + return [(m["endpoint_id"], m["is_default"]) for m in rows if m["kind"] == kind] + + +def _set(client, **defaults): + return client.put("/api/settings/generation", json={"default_models": defaults}) + + +def test_no_default_keeps_the_catalogue_order(auth_client, models): + assert _order(auth_client, "image_to_video") == [(I2V, False), (VEO, False)] + assert auth_client.get("/api/settings/generation").json()["data"]["default_models"] == {} + + +def test_a_default_is_saved_flagged_and_listed_first(auth_client, models): + response = _set(auth_client, image_to_video=VEO) + + assert response.status_code == 200, response.text + assert response.json()["data"]["default_models"] == {"image_to_video": VEO} + assert _order(auth_client, "image_to_video") == [(VEO, True), (I2V, False)] + # Other kinds are untouched. + assert _order(auth_client, "text_to_image") == [(T2I, False)] + + +def test_setting_one_kind_keeps_the_others_and_null_clears(auth_client, models): + _set(auth_client, image_to_video=VEO, text_to_image=T2I) + _set(auth_client, image_to_image=I2I) + body = auth_client.get("/api/settings/generation").json()["data"] + assert body["default_models"] == { + "image_to_video": VEO, "text_to_image": T2I, "image_to_image": I2I, + } + + _set(auth_client, image_to_video=None, text_to_image="") + body = auth_client.get("/api/settings/generation").json()["data"] + assert body["default_models"] == {"image_to_image": I2I} + assert _order(auth_client, "image_to_video") == [(I2V, False), (VEO, False)] + + +def test_saving_the_key_alone_leaves_the_defaults(auth_client, models): + _set(auth_client, image_to_video=VEO) + auth_client.put("/api/settings/generation", json={"fal_api_key": "fal-key"}) + assert auth_client.get("/api/settings/generation").json()["data"]["default_models"] == { + "image_to_video": VEO + } + + +@pytest.mark.parametrize( + "defaults", + [ + {"image_to_video": T2I}, # a model of another kind + {"text_to_image": "fal-ai/nowhere/x"}, # not in the catalogue + {"text_to_video": T2I}, # no such kind + {"text_to_image": 7}, # not an endpoint id + ], +) +def test_a_default_that_cannot_be_used_is_refused_and_nothing_saved(auth_client, models, defaults): + response = _set(auth_client, image_to_image=I2I, **defaults) + + assert response.status_code == 422, response.text + assert response.json()["detail"]["code"] == "invalid_default_model" + assert auth_client.get("/api/settings/generation").json()["data"]["default_models"] == {} + + +def test_a_switched_off_default_falls_back_to_the_catalogue_order(auth_client, session, models): + _set(auth_client, image_to_video=VEO) + veo = session.get(GenerationModel, models[VEO].id) + veo.is_active = False + session.add(veo) + session.commit() + + assert _order(auth_client, "image_to_video") == [(I2V, False)] + assert auth_client.get("/api/settings/generation").json()["data"]["default_models"] == {} + # A switched-off model cannot be chosen either. + assert _set(auth_client, image_to_video=VEO).status_code == 422 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index c81bd33..9f6acea 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -255,10 +255,13 @@ def test_the_generation_catalogue_is_seeded(alembic_config): "fal-ai/kling-video/v2.5-turbo/pro/image-to-video", "fal-ai/minimax/hailuo-02/standard/image-to-video", "fal-ai/veo3.1/fast/image-to-video", + "fal-ai/flux-pro/v1.1-ultra", + "fal-ai/flux-2-pro", + "fal-ai/flux-pro/v1.1", } kinds = [row["kind"] for row in rows] assert {kind: kinds.count(kind) for kind in set(kinds)} == { - "text_to_image": 3, + "text_to_image": 6, "image_to_image": 3, "image_to_video": 3, } @@ -288,6 +291,55 @@ def test_the_generation_catalogue_is_seeded(alembic_config): assert json.loads(by_endpoint[endpoint]["options"])["custom_size"]["multiple_of"] == 16 assert "custom_size" not in json.loads(by_endpoint["fal-ai/nano-banana-2"]["options"]) + # The FLUX Pro tier sorts ahead of schnell, so a caller that takes the first text → + # image model gets Pro rather than the draft model. + first = min((r for r in rows if r["kind"] == "text_to_image"), key=lambda r: r["sort_order"]) + assert first["endpoint_id"] == "fal-ai/flux-pro/v1.1-ultra" + ultra = json.loads(by_endpoint["fal-ai/flux-pro/v1.1-ultra"]["options"]) + assert "16:9" in ultra["aspect_ratios"] and ultra["image_sizes"] == [] + assert ultra["custom_size"] is None and ultra["max_outputs"] == 4 + flux2 = json.loads(by_endpoint["fal-ai/flux-2-pro"]["options"]) + assert flux2["max_outputs"] == 1 # FLUX.2 [pro] has no num_images + assert flux2["custom_size"]["max_pixels"] == 4_194_304 + pro = json.loads(by_endpoint["fal-ai/flux-pro/v1.1"]["options"]) + assert pro["custom_size"] == {"max_width": 1440, "max_height": 1440, "multiple_of": 32, + "max_pixels": None} + + +def test_the_flux_pro_seed_keeps_an_admins_rows(alembic_config): + """A FLUX Pro row an admin added by hand is not duplicated on upgrade, and the + downgrade removes only the rows the revision itself inserted and nobody edited.""" + config, db_path = alembic_config + command.upgrade(config, "aecf97cd87b1") + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT INTO generationmodel (id, endpoint_id, kind, label, note, sort_order," + " is_active, image_field_is_list, max_images, options, extra_params, created_at," + " updated_at) VALUES ('mine', 'fal-ai/flux-pro/v1.1', 'text_to_image', 'My Pro'," + " '', 1, 1, 0, 0, '{}', '{}', '2026-01-01', '2026-01-02')" + ) + + command.upgrade(config, "head") + with sqlite3.connect(db_path) as conn: + rows = conn.execute( + "SELECT id, endpoint_id FROM generationmodel WHERE endpoint_id LIKE 'fal-ai/flux-%pro%'" + " AND endpoint_id != 'fal-ai/flux-pro/kontext'" + ).fetchall() + assert sorted(e for _id, e in rows) == [ + "fal-ai/flux-2-pro", "fal-ai/flux-pro/v1.1", "fal-ai/flux-pro/v1.1-ultra", + ] + assert dict((e, i) for i, e in rows)["fal-ai/flux-pro/v1.1"] == "mine" + # An admin's later edit to a seeded row keeps it through a downgrade. + conn.execute( + "UPDATE generationmodel SET updated_at = '2030-01-01' WHERE endpoint_id = 'fal-ai/flux-2-pro'" + ) + + command.downgrade(config, "aecf97cd87b1") + with sqlite3.connect(db_path) as conn: + left = {r[0] for r in conn.execute("SELECT endpoint_id FROM generationmodel")} + assert "fal-ai/flux-pro/v1.1-ultra" not in left + assert {"fal-ai/flux-2-pro", "fal-ai/flux-pro/v1.1"} <= left + def test_the_generation_downgrade_survives_real_foreign_key_references(alembic_config): """Dropping the `ai_*` columns recreates `asset`, the operation `7d4b9c1a6f28` found diff --git a/docs/m8-ai-generation.md b/docs/m8-ai-generation.md index 15db4fd..66999c8 100644 --- a/docs/m8-ai-generation.md +++ b/docs/m8-ai-generation.md @@ -261,12 +261,26 @@ Envelopes and errors follow the house contract (`{"data": …}`, `image` / `megapixel` / `second` / `video`; `unit_price` is not negative. The admin form repeats the rules a person is likely to trip over before sending. -### The fal key -`GET /api/settings/generation` → `{"data": {"fal_key_configured": bool}}`. +### The fal key, and each user's default models +`GET /api/settings/generation` → `{"data": {"fal_key_configured": bool, +"default_models": {kind: endpoint_id}}}`. `PUT /api/settings/generation` with `{"fal_api_key": "…"}` stores it encrypted (setting key `fal_api_key`, in `settings_store.SECRET_KEYS`); `""` removes it; omitted leaves it alone. The key is never returned, masked or otherwise. +`default_models` in the same `PUT` sets the model each kind starts on, per user (setting +key `default_generation_models`, `app/generation/defaults.py`). Only the kinds named +change; `null` or `""` clears one. An endpoint that is not an active row of that kind is +`422 invalid_default_model`, and nothing in the request is saved. Kept by endpoint id, not +row id, so it means the same thing on every install and is what Video Creator's +per-project choice names too. + +`GET /api/generate/models` applies it: each kind's default is listed **first** in its kind +and flagged `is_default: true`. Moving it, not only flagging it, is the point: Video +Creator takes the first model of a kind when a project names none, so it follows the +user's choice without knowing the setting exists. A default whose row was switched off +or removed reads as no default. + ### Generating `POST /api/generate` → `202 {"data": ActivityJobRead}` ```json @@ -441,6 +455,12 @@ purpose. The API sections above have been brought into line with each. was `all()`, which called a total mixing a bill and an estimate exact). The asset's "AI cost (est.)" and the usage panel's caveat follow it. +- Three FLUX Pro text → image rows were added after M8 (`b7d41e9c2a60`): FLUX1.1 [pro] + ultra, FLUX.2 [pro] and FLUX1.1 [pro], at sort order 5–7, ahead of schnell's 10. Video + Creator had been making every unattended picture with schnell, the draft model, + because it sorted first. Their `custom_size` limits come from Black Forest Labs' and + fal's documentation, not from fal's schema, which states none. + **Frontend** - The four option lists, and the matching `params`, are `string | number` end to end, so a value goes back exactly as the row declared it. diff --git a/frontend/src/api/generate.ts b/frontend/src/api/generate.ts index ed6c71c..ef2676a 100644 --- a/frontend/src/api/generate.ts +++ b/frontend/src/api/generate.ts @@ -80,6 +80,11 @@ export interface GenerationModel { unit_price: number | null price_unit: string | null price_currency: string | null + /** + * The signed-in user's default for this kind (Settings). Per user, never part of the + * row, and the list already puts this model first in its kind. + */ + is_default?: boolean created_at: string updated_at: string } @@ -87,7 +92,7 @@ export interface GenerationModel { /** What an admin writes. Everything but the server's own bookkeeping. */ export type GenerationModelInput = Omit< GenerationModel, - 'id' | 'created_at' | 'updated_at' + 'id' | 'created_at' | 'updated_at' | 'is_default' > /** diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 2b14c17..3a165e0 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -1,4 +1,5 @@ import client from '@/api/client' +import type { GenerationKind } from '@/api/generate' /** * Per-user settings: the credentials and provider choices enrichment runs on. @@ -35,9 +36,14 @@ export const speechSettingsApi = { }, } -/** M8. Only the fal.ai key is per user; the model catalogue is global, in `api/generate`. */ +/** + * M8. The fal.ai key and the model each kind starts on are per user; the model catalogue + * itself is global, in `api/generate`. + */ export interface GenerationSettings { fal_key_configured: boolean + /** `{kind: endpoint_id}`, only for kinds whose default is still an active model. */ + default_models: Partial> } export const generationSettingsApi = { @@ -50,6 +56,8 @@ export const generationSettingsApi = { update(changes: { /** '' clears the stored key; omit the field to leave it alone. */ fal_api_key?: string + /** Only the kinds named change; null goes back to the catalogue's own order. */ + default_models?: Partial> }): Promise { return client .put>('/settings/generation', changes) diff --git a/frontend/src/components/GenerateForm.test.tsx b/frontend/src/components/GenerateForm.test.tsx index f2dcd5d..a9d1507 100644 --- a/frontend/src/components/GenerateForm.test.tsx +++ b/frontend/src/components/GenerateForm.test.tsx @@ -168,6 +168,13 @@ describe('GenerateForm: what it offers', () => { expect(modelNames()).toEqual(['FLUX dev', 'FLUX schnell']) }) + it('starts on the user’s default model rather than the first by sort order', () => { + renderForm({}, [{ ...SCHNELL, is_default: true }, DEV, EDIT_ONE, KLING]) + + expect(modelNames()).toEqual(['FLUX dev', 'FLUX schnell']) + expect(screen.getByLabelText('Model')).toHaveValue('schnell') + }) + it('offers image → image or image → video for one base', async () => { const user = userEvent.setup() renderForm({ bases: [image('b1')] }) diff --git a/frontend/src/components/GenerateForm.tsx b/frontend/src/components/GenerateForm.tsx index 1ed46b0..0744ccc 100644 --- a/frontend/src/components/GenerateForm.tsx +++ b/frontend/src/components/GenerateForm.tsx @@ -183,9 +183,12 @@ export default function GenerateForm({ title, bases = [], preset, onCancel }: Pr // Derived rather than stored, so a catalogue that arrives after the form mounts, or a // kind switch that empties the old choice, still lands on something offered. + // The user's default (Settings) before the first by sort order: `candidates` is + // re-sorted here, so the server's default-first order does not survive to `[0]`. const model = candidates.find((m) => m.id === modelId) ?? candidates.find((m) => m.endpoint_id === preset?.model) ?? + candidates.find((m) => m.is_default) ?? candidates[0] ?? null const presetModelGone = diff --git a/frontend/src/components/GenerationSettings.tsx b/frontend/src/components/GenerationSettings.tsx index 0082bbd..69530d7 100644 --- a/frontend/src/components/GenerationSettings.tsx +++ b/frontend/src/components/GenerationSettings.tsx @@ -39,6 +39,7 @@ export default function GenerationSettingsPanel() { + ) @@ -141,6 +142,103 @@ function FalKey() { ) } +/** + * Which model each kind starts on, here and in Video Creator, which takes the first + * model of a kind when a project names none. Per user, unlike the catalogue below, so + * it is offered to everyone rather than only to admins. + */ +function DefaultModels() { + const id = useId() + const models = useGenerationStore((s) => s.models) + const ensureLoaded = useGenerationStore((s) => s.ensureLoaded) + const reload = useGenerationStore((s) => s.load) + const [chosen, setChosen] = useState> | null>( + null + ) + const [saving, setSaving] = useState(false) + const [saved, flashSaved] = useSavedFlash() + const [error, setError] = useState(null) + + useEffect(() => { + ensureLoaded() + let current = true + generationSettingsApi + .get() + .then((s) => { + if (current) setChosen(s.default_models ?? {}) + }) + .catch((err) => { + if (current) setError(apiErrorMessage(err, 'Could not load the default models')) + }) + return () => { + current = false + } + }, [ensureLoaded]) + + const choose = async (kind: GenerationKind, endpoint: string) => { + setSaving(true) + setError(null) + try { + const next = await generationSettingsApi.update({ + default_models: { [kind]: endpoint || null }, + }) + setChosen(next.default_models ?? {}) + // The forms read the shared list, whose order and `is_default` follow this. + await reload() + flashSaved() + } catch (err) { + setError(apiErrorMessage(err, 'Could not save the default model')) + } finally { + setSaving(false) + } + } + + return ( +
+

+ Default models +

+

+ What each generate form starts on, and what Video Creator uses when a project does + not name a model. +

+ +
+ {GENERATION_KINDS.map((kind) => { + const ofKind = models.filter((m) => m.kind === kind) + return ( +
+ + +
+ ) + })} +
+ {saved && ( +

+ + Saved +

+ )} +
+ ) +} + /** The editable shape: every field as the control holds it, converted on save. */ interface ModelForm { endpoint_id: string diff --git a/frontend/src/views/SettingsView.test.tsx b/frontend/src/views/SettingsView.test.tsx index bf4d6e3..628cd91 100644 --- a/frontend/src/views/SettingsView.test.tsx +++ b/frontend/src/views/SettingsView.test.tsx @@ -7,6 +7,7 @@ import { embeddingsApi, type EmbeddingCoverage } from '@/api/embeddings' import { generateApi } from '@/api/generate' import { useActivityStore } from '@/stores/activity' import { useAuthStore } from '@/stores/auth' +import { useGenerationStore } from '@/stores/generation' import { embeddingSettingsApi, generationSettingsApi, @@ -98,7 +99,10 @@ beforeEach(() => { by_provider: [], }) // And the generation panel, which reads the fal key's state and the catalogue. - vi.spyOn(generationSettingsApi, 'get').mockResolvedValue({ fal_key_configured: false }) + vi.spyOn(generationSettingsApi, 'get').mockResolvedValue({ + fal_key_configured: false, + default_models: {}, + }) vi.spyOn(generateApi, 'models').mockResolvedValue([]) useAuthStore.getState().reset() }) @@ -397,7 +401,7 @@ describe('SettingsView generation (M8)', () => { it('saves a pasted fal.ai key and clears the box', async () => { const update = vi .spyOn(generationSettingsApi, 'update') - .mockResolvedValue({ fal_key_configured: true }) + .mockResolvedValue({ fal_key_configured: true, default_models: {} }) renderView() const input = await screen.findByLabelText('fal.ai API key') @@ -415,10 +419,13 @@ describe('SettingsView generation (M8)', () => { }) it('removes the stored fal.ai key with an empty string', async () => { - vi.spyOn(generationSettingsApi, 'get').mockResolvedValue({ fal_key_configured: true }) + vi.spyOn(generationSettingsApi, 'get').mockResolvedValue({ + fal_key_configured: true, + default_models: {}, + }) const update = vi .spyOn(generationSettingsApi, 'update') - .mockResolvedValue({ fal_key_configured: false }) + .mockResolvedValue({ fal_key_configured: false, default_models: {} }) renderView() await userEvent.click( @@ -428,15 +435,44 @@ describe('SettingsView generation (M8)', () => { await waitFor(() => expect(update).toHaveBeenCalledWith({ fal_api_key: '' })) }) + it('saves a default model per kind and reloads the shared catalogue', async () => { + useGenerationStore.getState().reset() + vi.spyOn(generateApi, 'models').mockResolvedValue([DEV, KLING]) + const update = vi.spyOn(generationSettingsApi, 'update').mockResolvedValue({ + fal_key_configured: false, + default_models: { image_to_video: KLING.endpoint_id }, + }) + renderView() + + const select = await screen.findByLabelText('Image → video') + await waitFor(() => expect(select).toBeEnabled()) + await userEvent.selectOptions(select, KLING.endpoint_id) + + await waitFor(() => + expect(update).toHaveBeenCalledWith({ + default_models: { image_to_video: KLING.endpoint_id }, + }) + ) + await waitFor(() => expect(select).toHaveValue(KLING.endpoint_id)) + // Once on mount, once after saving, so the forms follow the new default. + await waitFor(() => + expect( + vi.mocked(generateApi.models).mock.calls.filter((c) => !c[0]).length + ).toBeGreaterThanOrEqual(2) + ) + }) + it('shows everyone else the catalogue, read-only', async () => { signInAs(false) vi.spyOn(generateApi, 'models').mockResolvedValue([DEV, KLING]) renderView() const generation = await panel() - expect(await generation.findByText('FLUX.1 [dev]')).toBeInTheDocument() + // `option` skipped: the default-model selects list the same labels. + const notOption = { ignore: 'script, style, option' } + expect(await generation.findByText('FLUX.1 [dev]', notOption)).toBeInTheDocument() expect(generation.getByText('≈ $0.025 per megapixel, list price')).toBeInTheDocument() - expect(generation.getByText('Kling 2.5')).toBeInTheDocument() + expect(generation.getByText('Kling 2.5', notOption)).toBeInTheDocument() expect(generateApi.models).toHaveBeenCalledWith(false) expect(generation.queryByRole('button', { name: /add a model/i })).toBeNull() @@ -657,6 +693,10 @@ describe('SettingsView generation (M8)', () => { await user.click(generation.getByRole('button', { name: 'Delete FLUX.1 [dev]' })) await waitFor(() => expect(remove).toHaveBeenCalledWith('dev')) - await waitFor(() => expect(generation.queryByText('FLUX.1 [dev]')).toBeNull()) + await waitFor(() => + expect( + generation.queryByText('FLUX.1 [dev]', { ignore: 'script, style, option' }) + ).toBeNull() + ) }) })