agharsallah
feat(media): enhance media request handling with timeout configuration and improved serialization
846eb30 | """Image-generation serving for Modal — an OpenAI-compatible ``/v1/images/generations`` route. | |
| Mirrors ``service.py``'s shape (one autoscaling ``@app.function`` per model, weights on the | |
| shared Hugging Face cache volume, ``serialized=True``) but a diffusion model isn't a chat | |
| model, so it can't ride the vLLM recipe. Instead each endpoint serves a small FastAPI ASGI | |
| app that loads the pipeline once per container and answers the OpenAI images shape | |
| (``{model, prompt, size, n, response_format}`` → ``{data: [{b64_json}]}``). The engine's | |
| OpenAI SDK client (``client.images.generate``) therefore calls it unchanged. | |
| Deploy: uv run scripts/deploy_modal.py images --keep-warm | |
| """ | |
| from __future__ import annotations | |
| from collections.abc import Iterable | |
| import modal | |
| from media_catalogue import CUDA_IMAGE, HF_CACHE_PATH, HF_SECRET_NAME, PYTHON_VERSION, ImageModel | |
| hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True) | |
| def build_image(cfg: ImageModel) -> modal.Image: | |
| image = modal.Image.from_registry(CUDA_IMAGE, add_python=PYTHON_VERSION).entrypoint([]) | |
| # A ``git+https://`` pip dep needs git in the builder, and the base CUDA image has | |
| # none. Add it only when a git source is present (released wheels need nothing). | |
| if any("git+" in pkg for pkg in cfg.extra_pip): | |
| image = image.apt_install("git") | |
| return image.uv_pip_install("torch", *cfg.extra_pip).env( | |
| {"HF_HUB_CACHE": HF_CACHE_PATH, "HF_XET_HIGH_PERFORMANCE": "1"} | |
| ) | |
| def register_image_model(app: modal.App, cfg: ImageModel) -> modal.Function: | |
| image = build_image(cfg) | |
| target_inputs = max(1, (cfg.max_concurrent_inputs * 3) // 4) | |
| # Gated repos (FLUX.2) need a Hugging Face token at download time. | |
| secrets = [modal.Secret.from_name(HF_SECRET_NAME)] if cfg.gated else [] | |
| # Capture plain primitives (no catalogue class) into the closure: with serialized=True | |
| # the function is pickled and unpickled in the container, which does NOT have the | |
| # ``media_catalogue`` module — so referencing ``cfg`` directly inside serve() crashes | |
| # the container on deserialize (ModuleNotFoundError). Mirrors service.py, whose | |
| # serialized serve() captures only a plain command list. | |
| repo_id = cfg.repo_id | |
| pipeline_class = cfg.pipeline_class | |
| dtype_name = cfg.dtype | |
| steps = cfg.steps | |
| guidance = cfg.guidance | |
| cpu_offload = cfg.cpu_offload | |
| cache_path = HF_CACHE_PATH | |
| def serve(): | |
| import base64 | |
| import io | |
| import diffusers | |
| import torch | |
| from fastapi import FastAPI | |
| # Resolve the pipeline class by name. ``DiffusionPipeline`` auto-resolves newer | |
| # architectures (FLUX.2) from the repo config; classic models fall back to the | |
| # text2image autodetector. | |
| pipeline_cls = getattr(diffusers, pipeline_class, diffusers.AutoPipelineForText2Image) | |
| dtype = getattr(torch, dtype_name, torch.float16) | |
| # Load the pipeline once per container; subsequent requests reuse it. | |
| pipe = pipeline_cls.from_pretrained(repo_id, torch_dtype=dtype, cache_dir=cache_path) | |
| # CPU offload keeps peak VRAM low (only the active module is resident) so a large | |
| # model loads on a modest GPU; otherwise pin the whole pipeline on-device for speed. | |
| if cpu_offload: | |
| pipe.enable_model_cpu_offload() | |
| else: | |
| pipe = pipe.to("cuda") | |
| web = FastAPI() | |
| def models() -> dict: | |
| return {"object": "list", "data": [{"id": repo_id, "object": "model"}]} | |
| # Take the JSON body as a ``dict`` param (not a raw ``Request``): with | |
| # ``from __future__ import annotations`` active, FastAPI resolves the annotation | |
| # string against module globals, where ``Request`` (a local import inside serve()) | |
| # is invisible — so a ``request: Request`` param is mis-read as a query field and | |
| # 422s. ``dict`` resolves via builtins and is parsed as the request body. | |
| async def generate(body: dict) -> dict: | |
| prompt = str(body.get("prompt", "")) | |
| try: | |
| w, h = (int(x) for x in str(body.get("size", "1024x1024")).lower().split("x")) | |
| except Exception: | |
| w, h = 1024, 1024 | |
| n = max(1, int(body.get("n", 1) or 1)) | |
| # A distilled (CFG-free) model wants no guidance_scale at all — pass it only | |
| # when configured, matching the reference impl's prompt-and-steps-only call. | |
| kwargs: dict = {"prompt": prompt, "num_inference_steps": steps, "width": w, "height": h} | |
| if guidance is not None: | |
| kwargs["guidance_scale"] = guidance | |
| out: list[dict] = [] | |
| for _ in range(n): | |
| img = pipe(**kwargs).images[0] | |
| buf = io.BytesIO() | |
| img.save(buf, format="PNG") | |
| out.append({"b64_json": base64.b64encode(buf.getvalue()).decode("ascii")}) | |
| return {"created": 0, "data": out} | |
| return web | |
| return serve | |
| def register_all(app: modal.App, configs: Iterable[ImageModel]) -> None: | |
| for cfg in configs: | |
| register_image_model(app, cfg) | |