Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions backend/alembic/versions/20261102_1200_seed_flux_pro.py
Original file line number Diff line number Diff line change
@@ -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"]},
)
90 changes: 90 additions & 0 deletions backend/app/generation/defaults.py
Original file line number Diff line number Diff line change
@@ -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}
13 changes: 9 additions & 4 deletions backend/app/routers/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
19 changes: 16 additions & 3 deletions backend/app/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -92,16 +93,20 @@ 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])
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),
)
)


Expand All @@ -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)


Expand Down
8 changes: 8 additions & 0 deletions backend/app/schemas_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions backend/app/settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
Loading
Loading