multi-agent-lab / modal /image_service.py
agharsallah
feat(media): enhance media request handling with timeout configuration and improved serialization
846eb30
Raw
History Blame Contribute Delete
5.66 kB
"""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
@app.function(
name=cfg.endpoint_name,
image=image,
gpu=cfg.gpu,
volumes={HF_CACHE_PATH: hf_cache_vol},
secrets=secrets,
scaledown_window=cfg.scaledown_window,
timeout=cfg.request_timeout,
serialized=True,
)
@modal.concurrent(max_inputs=cfg.max_concurrent_inputs, target_inputs=target_inputs)
@modal.asgi_app()
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()
@web.get("/v1/models")
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.
@web.post("/v1/images/generations")
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)