1
0
mirror of https://github.com/huggingface/diffusers.git synced 2026-10-24 20:59:27 +03:00
Files
diffusers/tests/others/test_cli_serve.py
2026-10-09 23:42:55 +05:30

923 lines
43 KiB
Python

# Copyright 2026 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for `diffusers-cli serve`.
No weights are loaded: the diffusers backend runs a fake pipeline and the engine backend talks to a fake
SGLang / vLLM-Omni server through an httpx mock transport.
"""
import base64
import inspect
import io
import json
import subprocess
import sys
import threading
from argparse import ArgumentParser, Namespace
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from huggingface_hub.utils import httpx
from PIL import Image
from diffusers.commands.generate import GenerateCommand
from diffusers.commands.serve import ServeCommand
from diffusers.commands.serve.backends import (
DiffusersBackend,
EngineBackend,
backend_capabilities,
backend_kwargs,
backend_output_types,
)
from diffusers.commands.serve.generations import Generation, GenerationQueue
from diffusers.commands.serve.manifest import GMSError, Manifest, ServedModel, blocks_signature, derive_manifest
from diffusers.modular_pipelines import InputParam, ModularPipelineBlocks, OutputParam
from diffusers.utils import logging
from diffusers.utils.testing_utils import CaptureLogger
MODEL_ID = "test-org/fake-model"
WAIT = {"Prefer": "wait=30"}
class FakePipeline:
_lora_loadable_modules = ["transformer", "text_encoder"]
device = torch.device("cpu")
num_timesteps = 0
def __init__(self):
self.calls = []
self.lora_calls = []
self.release = threading.Event()
self.release.set()
self.started = threading.Event()
self.error = None
self.result = None
def __call__(
self,
prompt: str | list[str] = None,
negative_prompt: str | list[str] | None = None,
image: Image.Image | None = None,
height: int | None = None,
width: int | None = None,
num_inference_steps: int = 4,
sigmas: list[float] | None = None,
guidance_scale: float = 3.5,
num_images_per_prompt: int | None = 1,
generator: torch.Generator | None = None,
latents: torch.Tensor | None = None,
output_type: str | None = "pil",
return_dict: bool = True,
callback_on_step_end=None,
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
):
self.calls.append(
{
"prompt": prompt,
"negative_prompt": negative_prompt,
"image": image,
"height": height,
"width": width,
"num_inference_steps": num_inference_steps,
"guidance_scale": guidance_scale,
"generator": generator,
}
)
self.started.set()
self.release.wait(timeout=30)
if self.error is not None:
raise self.error
self.num_timesteps = num_inference_steps
self._interrupt = False
for step in range(num_inference_steps):
if self._interrupt:
break
callback_on_step_end(self, step, 0, {})
if self.result is not None:
return self.result
size = (width or 32, height or 32)
return SimpleNamespace(images=[Image.new("RGB", size, "red") for _ in range(num_images_per_prompt)])
def load_lora_weights(self, repo, adapter_name=None):
self.lora_calls.append(("load", repo, adapter_name))
def set_adapters(self, names, adapter_weights=None):
self.lora_calls.append(("set", names, adapter_weights))
def unload_lora_weights(self):
self.lora_calls.append(("unload",))
FAKE_SIGNATURE = inspect.signature(FakePipeline.__call__)
class FakeImageBlock(ModularPipelineBlocks):
"""A one-block modular pipeline that declares its inputs the way the built-in blocks do, mostly untyped."""
model_name = "fake"
@property
def inputs(self):
return [
InputParam("prompt"),
InputParam("image"),
InputParam("width"),
InputParam("num_inference_steps", default=4),
InputParam("generator"),
]
@property
def intermediate_outputs(self):
return [OutputParam("images")]
def __call__(self, components, state):
block_state = self.get_block_state(state)
size = (block_state.width or 32, 32)
block_state.images = [Image.new("RGB", size, "red")]
self.set_block_state(state, block_state)
return components, state
def _args(**overrides):
values = {"backend": "diffusers", "compile": None, "lora": None, "fps": 8, "sampling_rate": None}
return Namespace(**{**values, **overrides})
def _served(backend="diffusers", manifest=None, pipeline_cls=FakePipeline, **arg_overrides):
args = _args(backend=backend, **arg_overrides)
signature = inspect.signature(pipeline_cls.__call__)
kwarg_names = backend_kwargs(backend, signature)
if manifest is None:
manifest = derive_manifest(MODEL_ID, pipeline_cls, signature, kwarg_names)
raw = manifest if isinstance(manifest, bytes) else json.dumps(manifest).encode()
return ServedModel(
Manifest(raw=raw, data=json.loads(raw)),
kwarg_names,
backend_output_types(backend),
backend_capabilities(args, pipeline_cls),
revision="abc123",
)
@pytest.fixture
def serve(tmp_path, monkeypatch):
"""Build a test client around a `DiffusersBackend` running `FakePipeline`. Returns `(client, pipeline)`."""
pytest.importorskip("fastapi", reason="`diffusers-cli serve` needs the `diffusers[serve]` extra")
from fastapi.testclient import TestClient
from diffusers.commands.serve.app import create_app
def _build(manifest=None, app_options=None, **arg_overrides):
pipeline = FakePipeline()
monkeypatch.setattr("diffusers.commands.serve.backends._load_pipeline", lambda args: pipeline)
served = _served(manifest=manifest, **arg_overrides)
backend = DiffusersBackend(_args(**arg_overrides), served)
app = create_app(served, GenerationQueue(backend, str(tmp_path)), **(app_options or {}))
return TestClient(app), pipeline
return _build
LATTICE_MANIFEST = {
"id": MODEL_ID,
"modalities": {"in": ["image"], "out": ["video"]},
"tasks": {
"text_to_video": {
"inputs": {
"prompt": {"type": "text", "required": True},
"negative_prompt": {"type": "text", "active_when": {"parameter": "guidance_scale", "gt": 1}},
"last_image": {"type": "image"},
},
"outputs": {"video": {"type": "video"}},
"parameters": {
"duration": {"type": "number", "min": 1, "max": 20, "lattice": {"frames": "8k+1", "fps": 24}},
"width": {"type": "integer", "default": 768, "min": 512, "max": 1536, "multiple_of": 64},
"steps": {"type": "integer", "default": 30, "min": 1, "max": 100},
"fps": {"type": "number"},
"guidance_scale": {"type": "number", "default": 3.0, "min": 1.0, "max": 10.0},
"guidance_type": {"type": "string", "enum": ["cfg", "distilled"], "default": "cfg"},
"seed": {"type": "integer"},
"video_decoder": {"type": "string", "enum": ["conv", "diffusion"], "default": "conv"},
},
"presets": {
"default": {},
"distilled": {"parameters": {"steps": 8, "guidance_scale": 1.0}},
"quality": {"parameters": {"steps": 40, "video_decoder": "diffusion"}},
},
}
},
}
class FakeVideoPipeline:
def __call__(
self,
prompt: str = None,
negative_prompt: str | None = None,
width: int = 768,
num_frames: int = 121,
frame_rate: float = 24.0,
num_inference_steps: int = 40,
guidance_scale: float = 4.0,
generator: torch.Generator | None = None,
):
pass
class TestManifest:
def test_derive_manifest_from_signature(self):
manifest = derive_manifest(MODEL_ID, FakePipeline, FAKE_SIGNATURE, backend_kwargs("diffusers", FAKE_SIGNATURE))
# An optional `image` kwarg yields one task without it and one that requires it.
assert list(manifest["tasks"]) == ["text_to_image", "image_to_image"], manifest["tasks"].keys()
text_to_image = manifest["tasks"]["text_to_image"]
assert text_to_image["inputs"] == {
"prompt": {"type": "text", "required": True},
"negative_prompt": {"type": "text"},
}
assert manifest["tasks"]["image_to_image"]["inputs"]["image"] == {"type": "image", "required": True}
assert text_to_image["outputs"] == {"image": {"type": "image"}}
# Core vocabulary names replace the pipeline's own; tensors, callbacks and output plumbing are dropped.
assert text_to_image["parameters"] == {
"height": {"type": "integer"},
"width": {"type": "integer"},
"steps": {"type": "integer", "default": 4},
"sigmas": {"type": "array", "items": {"type": "number"}},
"guidance_scale": {"type": "number", "default": 3.5},
"num_outputs": {"type": "integer", "default": 1},
"seed": {"type": "integer"},
}
assert text_to_image["x-diffusers"] == {
"parameters": {"steps": "num_inference_steps", "num_outputs": "num_images_per_prompt", "seed": "generator"}
}
assert manifest["adapters"] == {"lora": {"targets": ["transformer", "text_encoder"]}}
@pytest.mark.parametrize(
"body, code, pointer",
[
({"parameters": {"cfg": 3}}, "invalid_request", "/parameters/cfg"),
({"parameters": {"width": 700}}, "invalid_request", "/parameters/width"),
({"parameters": {"steps": 500}}, "invalid_request", "/parameters/steps"),
({"parameters": {"steps": "8"}}, "invalid_request", "/parameters/steps"),
({"inputs": {}}, "invalid_request", "/inputs/prompt"),
({"inputs": {"prompt": "a cat", "caption": "x"}}, "invalid_request", "/inputs/caption"),
({"task": "image_to_video"}, "invalid_request", "/task"),
({"task": []}, "invalid_request", "/task"),
({"parameters": []}, "invalid_request", "/parameters"),
({"parameters": {"seed": 10**30}}, "invalid_request", "/parameters/seed"),
({"parameters": {"duration": float("nan")}}, "invalid_request", "/parameters/duration"),
# The lattice pins duration to 24 fps, so another fps would deliver a different length.
({"parameters": {"duration": 4, "fps": 12}}, "invalid_request", "/parameters/fps"),
({"steps": 8}, "invalid_request", "/steps"),
({"preset": "quality"}, "invalid_request", "/preset"),
({"model": "someone/else"}, "not_found", None),
({"adapters": [{"type": "lora", "path": "hf:a/b"}]}, "not_implemented", "/adapters"),
({"output_format": "video/webm"}, "invalid_request", "/output_format"),
# `negative_prompt` is only honored while guidance_scale > 1.
(
{"inputs": {"prompt": "a cat", "negative_prompt": "blurry"}, "preset": "distilled"},
"invalid_request",
"/inputs/negative_prompt",
),
# `last_image` has no matching pipeline kwarg, so the server overlay disables it.
(
{"inputs": {"prompt": "a cat", "last_image": {"type": "image", "url": "https://x/y.png"}}},
"invalid_request",
"/inputs/last_image",
),
# A parameter the pipeline cannot set is refused rather than dropped, unless it is the default.
({"parameters": {"video_decoder": "diffusion"}}, "not_implemented", "/parameters/video_decoder"),
],
)
def test_resolve_rejects_invalid_requests(self, body, code, pointer):
served = _served(manifest=LATTICE_MANIFEST, pipeline_cls=FakeVideoPipeline)
with pytest.raises(GMSError) as error:
served.resolve({"model": MODEL_ID, "inputs": {"prompt": "a cat"}, **body})
assert (error.value.code, error.value.pointer) == (code, pointer), error.value.message
def test_resolve_rejects_non_http_media_urls(self):
served = _served()
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat", "image": {"type": "image", "url": "/etc/passwd"}}}
with pytest.raises(GMSError) as error:
served.resolve(body)
assert error.value.pointer == "/inputs/image/url", error.value.message
@pytest.mark.parametrize("path", ["hf:/etc/passwd", "hf:../loras/x", "/etc/passwd", "https://x/y.safetensors"])
def test_resolve_rejects_adapter_paths_that_are_not_repo_ids(self, path):
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}, "adapters": [{"type": "lora", "path": path}]}
with pytest.raises(GMSError) as error:
_served().resolve(body)
assert (error.value.code, error.value.pointer) == ("invalid_request", "/adapters/0/path"), error.value.message
def test_resolve_maps_request_to_pipeline_kwargs(self):
served = _served(manifest=LATTICE_MANIFEST, pipeline_cls=FakeVideoPipeline)
resolved = served.resolve(
{
"model": MODEL_ID,
"preset": "distilled",
"inputs": {"prompt": "a cat"},
"parameters": {"duration": 8, "seed": 7, "guidance_type": "cfg"},
}
)
# 8 s at 24 fps is 192 frames; the nearest point on the 8k+1 lattice is 193. The preset overrides the
# task defaults, and `guidance_type` at its default is accepted although the pipeline has no such kwarg.
assert resolved.kwargs == {
"prompt": "a cat",
"num_frames": 193,
"frame_rate": 24,
"width": 768,
"num_inference_steps": 8,
"guidance_scale": 1.0,
}, resolved.kwargs
assert resolved.seed == 7
assert resolved.task == "text_to_video"
def test_discovery_overlay(self):
served = _served(manifest=LATTICE_MANIFEST, pipeline_cls=FakeVideoPipeline)
assert served.overlay() == {
"tasks": {"text_to_video": {"presets": ["default", "distilled"]}},
"capabilities": ["sse", "wait"],
"limits": {"text_to_video": {"inputs": {"last_image": False}}},
}
class TestServeApp:
def test_generation_lifecycle(self, serve):
client, pipeline = serve()
body = {
"model": MODEL_ID,
"inputs": {"prompt": "a cat"},
"parameters": {"width": 64, "height": 48, "steps": 3, "seed": 11},
}
response = client.post("/v1/generations", json=body, headers=WAIT)
assert response.status_code == 201, response.text
generation = response.json()
assert generation["status"] == "complete", generation
assert generation["seed"] == 11
assert generation["revision"] == "abc123"
assert generation["progress"] is None
assert generation["usage"]["compute_seconds"] >= 0
call = pipeline.calls[0]
assert (call["prompt"], call["width"], call["height"], call["num_inference_steps"]) == ("a cat", 64, 48, 3)
assert torch.equal(call["generator"].get_state(), torch.Generator().manual_seed(11).get_state())
(artifact,) = generation["outputs"]["image"]
assert {k: artifact[k] for k in ("media_type", "width", "height")} == {
"media_type": "image/png",
"width": 64,
"height": 48,
}
image = Image.open(io.BytesIO(client.get(artifact["url"]).content))
assert (image.format, image.size) == ("PNG", (64, 48))
events = client.get(f"/v1/generations/{generation['id']}/events").text
assert events.count("event: progress") == 3, events
assert 'data: {"step": 3, "total_steps": 3, "fraction": 1.0}' in events
assert events.rstrip().split("\n\n")[-1].startswith("id: 4\nevent: complete\n")
# Resuming after the last progress event replays only what followed it.
resumed = client.get(f"/v1/generations/{generation['id']}/events", headers={"Last-Event-ID": "3"}).text
assert "event: progress" not in resumed and "event: complete" in resumed, resumed
assert client.delete(f"/v1/generations/{generation['id']}").status_code == 204
missing = client.get(f"/v1/generations/{generation['id']}")
assert (missing.status_code, missing.json()["error"]["code"]) == (404, "not_found")
assert client.get(artifact["url"]).status_code == 404
def test_inline_response_and_media_input(self, serve):
client, pipeline = serve()
buffer = io.BytesIO()
Image.new("RGB", (8, 8), "blue").save(buffer, format="PNG")
body = {
"model": MODEL_ID,
"inputs": {
"prompt": "a cat",
"image": {
"type": "image",
"base64": base64.b64encode(buffer.getvalue()).decode(),
"media_type": "image/png",
},
},
"output_format": "image/jpeg",
"response_format": "b64_json",
}
generation = client.post("/v1/generations", json=body, headers=WAIT).json()
assert generation["status"] == "complete", generation
# The task is inferred from the inputs: only `image_to_image` accepts an image.
assert pipeline.calls[0]["image"].size == (8, 8)
(artifact,) = generation["outputs"]["image"]
assert artifact["media_type"] == "image/jpeg" and "url" not in artifact
assert Image.open(io.BytesIO(base64.b64decode(artifact["base64"]))).format == "JPEG"
def test_joint_video_and_audio_outputs(self, serve, monkeypatch):
monkeypatch.setattr(
"diffusers.commands.serve.backends.export_to_video",
lambda frames, path, fps: Path(path).write_bytes(b"mp4"),
)
manifest = {
"id": MODEL_ID,
"tasks": {
"text_to_video": {
"inputs": {"prompt": {"type": "text", "required": True}},
"outputs": {"video": {"type": "video"}, "audio": {"type": "audio"}},
}
},
}
client, pipeline = serve(manifest=manifest)
pipeline.result = SimpleNamespace(
frames=[[Image.new("RGB", (32, 24)) for _ in range(16)]],
audio=np.zeros((1, 2, 8000), dtype=np.float32),
)
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}}
generation = client.post("/v1/generations", json=body, headers=WAIT).json()
assert generation["status"] == "complete", generation
# The fake pipeline takes no fps argument, so the video is encoded at the `--fps` default.
(video,) = generation["outputs"]["video"]
(audio,) = generation["outputs"]["audio"]
assert {k: v for k, v in video.items() if k != "url"} == {
"media_type": "video/mp4",
"width": 32,
"height": 24,
"duration": 2.0,
"fps": 8,
}
assert {k: v for k, v in audio.items() if k != "url"} == {
"media_type": "audio/wav",
"duration": 0.5,
"sample_rate": 16000,
}
assert client.get(audio["url"]).content[:4] == b"RIFF"
def test_validation_error_envelope(self, serve):
client, _ = serve()
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}, "parameters": {"cfg_scale": 3}}
response = client.post("/v1/generations", json=body)
assert response.status_code == 400
assert response.json() == {
"error": {
"code": "invalid_request",
"message": "task 'text_to_image' declares no parameter 'cfg_scale'",
"pointer": "/parameters/cfg_scale",
}
}
def test_failed_generation_returns_error_envelope(self, serve):
client, pipeline = serve()
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}}
envelope = {"error": {"code": "internal", "message": "RuntimeError: CUDA out of memory"}}
pipeline.error = RuntimeError("CUDA out of memory")
created = client.post("/v1/generations", json=body)
assert created.status_code == 201
# The event stream closes once the generation is done, so reading it to the end waits for the failure.
events = client.get(f"/v1/generations/{created.json()['id']}/events").text
assert 'event: error\ndata: {"code": "internal", "message": "RuntimeError: CUDA out of memory"}' in events
response = client.get(f"/v1/generations/{created.json()['id']}")
assert (response.status_code, response.json()) == (500, envelope)
# There is no `failed` status: a create that waited for the failure returns the envelope as well.
waited = client.post("/v1/generations", json=body, headers=WAIT)
assert (waited.status_code, waited.json()) == (500, envelope)
# A SystemExit from the backend must not end the worker: the next generation still runs.
pipeline.error = SystemExit("unsupported")
assert client.post("/v1/generations", json=body, headers=WAIT).status_code == 500
pipeline.error = None
assert client.post("/v1/generations", json=body, headers=WAIT).json()["status"] == "complete"
def test_health(self, serve):
client, _ = serve()
response = client.get("/health")
assert (response.status_code, response.json()) == (200, {"status": "ok"})
def test_request_id_is_generated_or_echoed(self, serve):
client, _ = serve()
assert client.get("/health").headers["x-request-id"], "a request without an id should be given one"
echoed = client.get("/v1/models/unknown", headers={"x-request-id": "req-1"})
assert (echoed.status_code, echoed.headers["x-request-id"]) == (404, "req-1")
def test_default_seed_applies_when_request_omits_seed(self, serve):
client, _ = serve(app_options={"default_seed": 7})
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}}
assert client.post("/v1/generations", json=body, headers=WAIT).json()["seed"] == 7
body["parameters"] = {"seed": 11}
assert client.post("/v1/generations", json=body, headers=WAIT).json()["seed"] == 11
def test_public_url_replaces_the_request_address_in_output_links(self, serve):
client, _ = serve(app_options={"public_url": "https://proxy.example/sandbox/proxy/8000/"})
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}}
generation = client.post("/v1/generations", json=body, headers=WAIT).json()
expected = f"https://proxy.example/sandbox/proxy/8000/v1/generations/{generation['id']}/files/image-0.png"
assert generation["outputs"]["image"][0]["url"] == expected
# The sandbox proxy forwards some requests with the published path still attached.
for path in ("/v1/models", "/sandbox/proxy/8000/v1/models"):
assert client.get(path).status_code == 200, path
prefixed = client.post("/sandbox/proxy/8000/v1/generations", json=body, headers=WAIT)
assert prefixed.status_code == 201, prefixed.text
def test_cors_is_opt_in(self, serve):
origin = {"Origin": "http://example.com"}
client, _ = serve()
assert "access-control-allow-origin" not in client.get("/health", headers=origin).headers
client, _ = serve(app_options={"enable_cors": True})
assert client.get("/health", headers=origin).headers["access-control-allow-origin"] == "*"
def test_cancel_queued_and_running(self, serve):
client, pipeline = serve()
pipeline.release.clear()
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}}
running = client.post("/v1/generations", json=body).json()
queued = client.post("/v1/generations", json=body).json()
assert queued["status"] == "queued", queued
assert pipeline.started.wait(timeout=30), "the worker never picked up the first generation"
assert client.post(f"/v1/generations/{queued['id']}/cancel").json()["status"] == "canceled"
client.post(f"/v1/generations/{running['id']}/cancel")
pipeline.release.set()
# The stream ends once the generation is done; a canceled generation emits no `complete` event.
events = client.get(f"/v1/generations/{running['id']}/events").text
assert "event: complete" not in events, events
canceled = client.get(f"/v1/generations/{running['id']}").json()
assert (canceled["status"], canceled["outputs"]) == ("canceled", None)
assert len(pipeline.calls) == 1, "the canceled queued generation must never reach the pipeline"
def test_request_adapters_are_loaded_then_unloaded(self, serve):
client, pipeline = serve()
body = {
"model": MODEL_ID,
"inputs": {"prompt": "a cat"},
"adapters": [
{"type": "lora", "path": "hf:someone/style", "scale": 0.8},
{"type": "lora", "path": "hf:someone/detail", "scale": 0.5, "targets": {"text_encoder": 0.1}},
],
}
generation = client.post("/v1/generations", json=body, headers=WAIT).json()
assert generation["status"] == "complete", generation
assert pipeline.lora_calls == [
("load", "someone/style", "request_0"),
("load", "someone/detail", "request_1"),
("set", ["request_0", "request_1"], [0.8, {"transformer": 0.5, "text_encoder": 0.1}]),
("unload",),
]
def test_adapters_not_advertised_when_compiled(self, serve):
client, _ = serve(compile="{}")
discovery = client.get(f"/v1/models/{MODEL_ID}").json()
assert "adapters" not in discovery["server"]["capabilities"]
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}, "adapters": [{"type": "lora", "path": "hf:a/b"}]}
response = client.post("/v1/generations", json=body)
assert (response.status_code, response.json()["error"]["code"]) == (501, "not_implemented")
def test_discovery_serves_manifest_bytes_verbatim(self, serve):
manifest = derive_manifest(MODEL_ID, FakePipeline, FAKE_SIGNATURE, backend_kwargs("diffusers", FAKE_SIGNATURE))
raw = json.dumps(manifest, indent=3).encode()
client, _ = serve(manifest=raw)
response = client.get(f"/v1/models/{MODEL_ID}")
assert raw in response.content
discovery = response.json()
assert discovery["spec"] == "gms/0.1"
assert discovery["manifest"] == manifest
assert discovery["server"]["tasks"] == {"text_to_image": {}, "image_to_image": {}}
assert discovery["server"]["adapters"] == {"max": 4, "sources": ["hf"]}
assert client.get("/v1/models").json() == {
"data": [
{
"id": MODEL_ID,
"modalities": {"in": ["image"], "out": ["image"]},
"tasks": ["text_to_image", "image_to_image"],
}
]
}
def test_unadvertised_capabilities_return_not_implemented(self, serve):
client, _ = serve()
for response in (
client.post("/v1/uploads", json={}),
client.get("/v1/generations/gen_x/outputs/video/stream"),
):
assert (response.status_code, response.json()["error"]["code"]) == (501, "not_implemented")
class FakeEngine:
"""The image and video routes SGLang and vLLM-Omni share, recording what the backend sends."""
def __init__(self):
self.requests = []
self.polls = 0
def __call__(self, request):
content_type = request.headers.get("content-type", "")
self.requests.append((request.method, request.url.path, content_type.split(";")[0], request.content))
path = request.url.path
if path in ("/v1/images/generations", "/v1/images/edits"):
buffer = io.BytesIO()
Image.new("RGB", (16, 8), "green").save(buffer, format="PNG")
return httpx.Response(200, json={"data": [{"b64_json": base64.b64encode(buffer.getvalue()).decode()}]})
if path == "/v1/videos" and request.method == "POST":
return httpx.Response(200, json={"id": "video_1", "status": "queued"})
if path == "/v1/videos/video_1" and request.method == "GET":
self.polls += 1
if self.polls < 2:
return httpx.Response(200, json={"id": "video_1", "status": "in_progress"})
return httpx.Response(200, json={"id": "video_1", "status": "completed", "fps": 16.0, "duration_s": 5.0})
if path == "/v1/videos/video_1/content":
return httpx.Response(200, content=b"mp4-bytes")
if path == "/v1/videos/video_1" and request.method == "DELETE":
return httpx.Response(200, json={"id": "video_1", "deleted": True})
return httpx.Response(404, text="no such route")
class FakeTextToVideoPipeline:
def __call__(
self,
prompt: str = None,
image: Image.Image = None,
height: int = 480,
width: int = 832,
num_frames: int = 81,
num_inference_steps: int = 50,
generator: torch.Generator | None = None,
):
pass
class TestModularPipeline:
def test_generation_runs_a_modular_pipeline(self, tmp_path, monkeypatch):
pytest.importorskip("fastapi", reason="`diffusers-cli serve` needs the `diffusers[serve]` extra")
from fastapi.testclient import TestClient
from diffusers.commands.serve.app import create_app
pipeline = FakeImageBlock().init_pipeline()
monkeypatch.setattr("diffusers.commands.serve.backends._load_pipeline", lambda args: pipeline)
signature = blocks_signature(pipeline.blocks)
kwarg_names = backend_kwargs("diffusers", signature)
manifest = derive_manifest(MODEL_ID, type(pipeline), signature, kwarg_names)
assert list(manifest["tasks"]) == ["text_to_image", "image_to_image"], manifest["tasks"].keys()
assert manifest["tasks"]["text_to_image"]["inputs"] == {"prompt": {"type": "text", "required": True}}
assert manifest["tasks"]["text_to_image"]["parameters"] == {
"steps": {"type": "integer", "default": 4},
"seed": {"type": "integer"},
}
raw = json.dumps(manifest).encode()
served = ServedModel(
Manifest(raw=raw, data=manifest),
kwarg_names,
backend_output_types("diffusers"),
backend_capabilities(_args(), type(pipeline)),
revision=None,
)
backend = DiffusersBackend(_args(), served)
client = TestClient(create_app(served, GenerationQueue(backend, str(tmp_path))))
body = {"model": MODEL_ID, "inputs": {"prompt": "a cat"}, "parameters": {"seed": 3}}
generation = client.post("/v1/generations", json=body, headers=WAIT).json()
assert generation["status"] == "complete", generation
assert generation["seed"] == 3
(artifact,) = generation["outputs"]["image"]
assert (artifact["media_type"], artifact["width"], artifact["height"]) == ("image/png", 32, 32)
class TestEngineBackend:
def _generate(self, tmp_path, monkeypatch, served, body):
monkeypatch.setattr("diffusers.commands.serve.backends._POLL_SECONDS", 0.0)
engine = FakeEngine()
client = httpx.Client(base_url="http://engine", transport=httpx.MockTransport(engine))
backend = EngineBackend("vllm", served, client)
resolved = served.resolve({"model": MODEL_ID, **body})
generation = Generation(served.id, None, resolved, seeded=served.seeded(resolved.task))
generation.directory = tmp_path
return engine, backend.generate(generation)
def test_exited_engine_is_unhealthy(self, tmp_path):
pytest.importorskip("fastapi", reason="`diffusers-cli serve` needs the `diffusers[serve]` extra")
from fastapi.testclient import TestClient
from diffusers.commands.serve.app import create_app
process = subprocess.Popen([sys.executable, "-c", "raise SystemExit(3)"])
process.wait()
served = _served(backend="vllm")
backend = EngineBackend("vllm", served, httpx.Client(base_url="http://engine"), process)
response = TestClient(create_app(served, GenerationQueue(backend, str(tmp_path)))).get("/health")
expected = {"status": "unhealthy", "reason": "the vllm backend exited with code 3"}
assert (response.status_code, response.json()) == (503, expected)
def test_derived_manifest_keeps_only_engine_kwargs(self):
manifest = derive_manifest(MODEL_ID, FakePipeline, FAKE_SIGNATURE, backend_kwargs("vllm", FAKE_SIGNATURE))
assert "sigmas" not in manifest["tasks"]["text_to_image"]["parameters"]
assert "steps" in manifest["tasks"]["text_to_image"]["parameters"]
def test_text_to_image_request_translation(self, tmp_path, monkeypatch):
body = {
"inputs": {"prompt": "a cat", "negative_prompt": "blurry"},
"parameters": {"width": 1024, "height": 768, "steps": 20, "num_outputs": 2, "seed": 5},
"output_format": "image/webp",
}
engine, outputs = self._generate(tmp_path, monkeypatch, _served(backend="vllm"), body)
((method, path, content_type, content),) = engine.requests
assert (method, path, content_type) == ("POST", "/v1/images/generations", "application/json")
assert json.loads(content) == {
"prompt": "a cat",
"negative_prompt": "blurry",
"num_inference_steps": 20,
"guidance_scale": 3.5,
"n": 2,
"seed": 5,
"size": "1024x768",
"response_format": "b64_json",
"output_format": "webp",
}
assert outputs == {"image": [{"file": "image-0.webp", "media_type": "image/webp", "width": 16, "height": 8}]}
assert (tmp_path / "image-0.webp").is_file()
def test_image_input_uses_multipart_edits(self, tmp_path, monkeypatch):
image = {"type": "image", "base64": base64.b64encode(b"png-bytes").decode(), "media_type": "image/png"}
body = {"inputs": {"prompt": "make it blue", "image": image}, "parameters": {"seed": 5}}
engine, _ = self._generate(tmp_path, monkeypatch, _served(backend="sglang"), body)
((method, path, content_type, content),) = engine.requests
assert (method, path, content_type) == ("POST", "/v1/images/edits", "multipart/form-data")
assert b'name="image"; filename="image-0.png"' in content and b"png-bytes" in content
assert b'name="prompt"\r\n\r\nmake it blue' in content
def test_video_job_is_polled_downloaded_and_deleted(self, tmp_path, monkeypatch):
served = _served(backend="vllm", pipeline_cls=FakeTextToVideoPipeline)
body = {"task": "text_to_video", "inputs": {"prompt": "a cat"}, "parameters": {"num_frames": 33, "seed": 5}}
engine, outputs = self._generate(tmp_path, monkeypatch, served, body)
assert [(method, path) for method, path, _, _ in engine.requests] == [
("POST", "/v1/videos"),
("GET", "/v1/videos/video_1"),
("GET", "/v1/videos/video_1"),
("GET", "/v1/videos/video_1/content"),
("DELETE", "/v1/videos/video_1"),
]
_, _, content_type, content = engine.requests[0]
assert content_type == "multipart/form-data"
assert b'name="num_frames"\r\n\r\n33' in content and b'name="size"\r\n\r\n832x480' in content
assert outputs == {"video": [{"file": "video-0.mp4", "media_type": "video/mp4", "fps": 16.0, "duration": 5.0}]}
assert (tmp_path / "video-0.mp4").read_bytes() == b"mp4-bytes"
def test_audio_tasks_are_not_hosted(self):
manifest = {
"id": MODEL_ID,
"tasks": {
"text_to_audio": {"inputs": {"prompt": {"type": "text"}}, "outputs": {"audio": {"type": "audio"}}}
},
}
assert _served(backend="vllm", manifest=manifest).tasks == {}
class TestGenerateCommand:
def _run(self, monkeypatch, client, argv):
parser = ArgumentParser()
GenerateCommand.register_subcommand(parser.add_subparsers())
monkeypatch.setattr("diffusers.commands.generate._POLL_SECONDS", 0.01)
monkeypatch.setattr(GenerateCommand, "_client", lambda self: client)
GenerateCommand(parser.parse_args(["generate", "--url", "http://testserver", *argv])).run()
def test_generate_saves_outputs(self, serve, tmp_path, monkeypatch):
client, pipeline = serve()
output = tmp_path / "saved"
argv = ["--inputs", '{"prompt": "a cat"}', "--parameters", '{"width": 64, "seed": 5}', "-o", str(output)]
self._run(monkeypatch, client, argv)
assert pipeline.calls[0]["prompt"] == "a cat"
assert [path.name for path in output.iterdir()] == ["image-0.png"], list(output.iterdir())
assert Image.open(output / "image-0.png").size == (64, 32)
def test_local_file_is_sent_as_a_media_value(self, serve, tmp_path, monkeypatch):
client, pipeline = serve()
source = tmp_path / "cat.png"
Image.new("RGB", (8, 8), "blue").save(source)
inputs = json.dumps({"prompt": "make it grey", "image": str(source)})
self._run(monkeypatch, client, ["--inputs", inputs, "-o", str(tmp_path / "saved")])
assert pipeline.calls[0]["image"].size == (8, 8)
def test_server_error_is_reported(self, serve, tmp_path, monkeypatch):
client, _ = serve()
argv = ["--inputs", '{"prompt": "a cat"}', "--parameters", '{"cfg": 1}', "-o", str(tmp_path)]
with pytest.raises(SystemExit, match=r"HTTP 400: invalid_request: .*\(at /parameters/cfg\)"):
self._run(monkeypatch, client, argv)
class FakeSandbox:
id = "sbx-1"
proxy_headers = {"Authorization": "Bearer hf_x", "X-Sandbox-Token": "tok"}
def __init__(self):
self.commands = []
self.uploads = []
self.killed = False
self.files = SimpleNamespace(upload=lambda local, remote: self.uploads.append((local, remote)))
def proxy_url_for(self, port, path="/"):
return f"https://job.hf.jobs/v1/sandboxes/s/proxy/{port}{path}"
def run(self, cmd, **kwargs):
self.commands.append(cmd)
return SimpleNamespace(exit_code=0, stdout="/usr/bin/python3\n")
def kill(self):
self.killed = True
class TestServeRemote:
def test_remote_starts_the_server_in_a_sandbox(self, tmp_path, monkeypatch):
sandbox = FakeSandbox()
created = {}
monkeypatch.setattr(
"diffusers.commands.serve.remote.Sandbox",
SimpleNamespace(create=lambda **kwargs: created.update(kwargs) or sandbox),
)
manifest = tmp_path / "gms.json"
manifest.write_text("{}")
parser = ArgumentParser()
ServeCommand.register_subcommand(parser.add_subparsers())
argv = ["serve", "-m", "org/model", "--dtype", "bf16", "--remote", "--flavor", "a10g-large"]
argv += ["--manifest", str(manifest), "--port", "9000", "--dependencies", "extra-package"]
ServeCommand(parser.parse_args(argv)).run()
assert created["flavor"] == "a10g-large"
assert created["image"] == "pytorch/pytorch:2.10.0-cuda12.8-cudnn9-runtime"
install, _, serve_argv = sandbox.commands
assert "fastapi" in install and "extra-package" in install, install
# The server runs under the interpreter the dependencies were installed into, not whatever is on PATH.
assert serve_argv[:3] == ["/usr/bin/python3", "-m", "diffusers.commands.diffusers_cli"], serve_argv
assert serve_argv[3:7] == ["--format", "quiet", "serve", "--model"], serve_argv
options = dict(zip(serve_argv[6::2], serve_argv[7::2]))
assert options["--dtype"] == "bf16"
# The sandbox server always binds the proxied port and builds output links on the proxied address.
assert options["--port"] == "8000"
assert options["--public-url"] == "https://job.hf.jobs/v1/sandboxes/s/proxy/8000/"
assert options["--manifest"] == "/tmp/diffusers-cli/gms.json"
assert sandbox.uploads == [(str(manifest), "/tmp/diffusers-cli/gms.json")]
assert "--remote" not in serve_argv and "--flavor" not in serve_argv, serve_argv
assert sandbox.killed, "the sandbox must be killed once the server stops"
@pytest.mark.parametrize(
"backend, image", [("sglang", "lmsysorg/sglang:latest"), ("vllm", "vllm/vllm-omni:latest")]
)
def test_engine_backend_defaults_to_the_engine_image(self, monkeypatch, backend, image):
created = {}
monkeypatch.setattr(
"diffusers.commands.serve.remote.Sandbox",
SimpleNamespace(create=lambda **kwargs: created.update(kwargs) or FakeSandbox()),
)
parser = ArgumentParser()
ServeCommand.register_subcommand(parser.add_subparsers())
ServeCommand(parser.parse_args(["serve", "-m", "org/model", "--backend", backend, "--remote"])).run()
assert created["image"] == image
ServeCommand(
parser.parse_args(["serve", "-m", "org/model", "--backend", backend, "--remote", "--image", "mine"])
).run()
assert created["image"] == "mine", "an explicit --image must win over the engine default"
class TestServeCommand:
def _parse(self, argv):
parser = ArgumentParser()
ServeCommand.register_subcommand(parser.add_subparsers())
return parser.parse_args(["serve", *argv])
def test_engine_backend_warns_about_ignored_pipeline_flags(self, monkeypatch):
pytest.importorskip("fastapi", reason="`diffusers-cli serve` needs the `diffusers[serve]` extra")
def stop(args):
raise RuntimeError("stop after the flag check")
monkeypatch.setattr("diffusers.commands.serve._resolve_pipeline", stop)
args = self._parse(["-m", "org/model", "--backend", "vllm", "--dtype", "bf16", "--vae-tiling"])
with CaptureLogger(logging.get_logger("diffusers-cli/serve")) as captured:
with pytest.raises(RuntimeError, match="stop after the flag check"):
ServeCommand(args).run()
assert "--dtype, --vae-tiling configure the in-process pipeline and are ignored" in captured.out
def test_context_parallel_is_rejected(self):
pytest.importorskip("fastapi", reason="`diffusers-cli serve` needs the `diffusers[serve]` extra")
with pytest.raises(SystemExit, match="--context-parallel is not supported"):
ServeCommand(self._parse(["-m", "org/model", "--context-parallel"])).run()