Skip to content
Open
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
38 changes: 38 additions & 0 deletions .zcodeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.DS_Store
.env

# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
node_modules/
bower_components/
jspm_packages/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,14 @@ vllm-omni-mlx is flexible and easy to use with:
| **TTS** — text → speech | Qwen3-TTS (CustomVoice · Base · VoiceDesign, 0.6B/1.7B) | ✅ verified end-to-end, buffered + streaming — [speech guide](docs/speech.md) |
| **Omni** — any-to-any chat | Qwen3-Omni 30B-A3B | 🚧 chat works via mlx-vlm; speech-out chat in progress |
| **ASR** — speech → text | Whisper, Qwen3-ASR, Voxtral, … | 🚧 planned — `/v1/audio/transcriptions` (#68) |
| **Diffusion** — text/image → image | — | 🚧 planned — roadmap (#2) |
| **Diffusion** — text/image → image | Z-Image-Turbo (6B, 9-step distilled, 4-bit) | ✅ `/v1/images/generations` (mflux seam) — SD tier next (#95) |

| Modality | Examples | Status |
| --- | --- | --- |
| **TTS** — text → speech | Qwen3-TTS (CustomVoice · Base · VoiceDesign, 0.6B/1.7B); VoxCPM2 (zero-shot · cloned · described voice, 30+ languages, 48 kHz) | ✅ verified end-to-end, buffered + streaming — [speech guide](docs/speech.md) |
| **Omni** — any-to-any chat | Qwen3-Omni 30B-A3B | 🚧 chat works via mlx-vlm; speech-out chat in progress |
| **ASR** — speech → text | Whisper, Qwen3-ASR, Voxtral, … | 🚧 planned — `/v1/audio/transcriptions` (#68) |
| **Diffusion** — text/image → image | — | 🚧 planned — roadmap (#2) |
| **Diffusion** — text/image → image | Z-Image-Turbo (6B, 9-step distilled, 4-bit) | ✅ `/v1/images/generations` (mflux seam) — SD tier next (#95) |

Text-only LLMs and image-in/text-out VLMs load through their engines but are
not this server's target categories.
Expand All @@ -76,6 +76,7 @@ python -m venv .venv && source .venv/bin/activate
pip install -e . # server core (MLX engine stack)
pip install -e '.[omni]' # + vision/audio models (mlx-vlm)
pip install -e '.[tts]' # + speech synthesis (mlx-audio)
pip install -e '.[image]' # + image generation, Z-Image-Turbo (mflux)
```

### Dependency footprint
Expand All @@ -85,15 +86,18 @@ pip install -e '.[tts]' # + speech synthesis (mlx-audio)
| core | `mlx-lm`, `starlette`, `uvicorn` | 38 | ~440 MB |
| + `[tts]` | + `mlx-audio` | 44 | ~560 MB |
| + `[omni]` | + `mlx-vlm` | 59 | ~750 MB |
| + `[image]` | + `mflux` | ~55 | ~870 MB |

The core install pulls in the MLX stack (`mlx` + `mlx-metal` kernels, `transformers`,
`tokenizers`, `huggingface_hub`) plus starlette/uvicorn and almost nothing else —
**no FastAPI, no pydantic, no torch**. The `[tts]` extra adds ~125 MB through
`mlx-audio` (miniaudio, sounddevice — still no torch). The `[omni]` extra adds
~315 MB through `mlx-vlm` (opencv, pillow, scipy — and mlx-audio, so `[omni]`
implies `[tts]`; that path does drag in fastapi/pydantic, contained to the
optional extra). Measured on macOS arm64 / Python 3.13 with mlx-lm 0.32,
mlx-vlm 0.7, and mlx-audio 0.5.7.
optional extra). The `[image]` extra adds ~870 MB through `mflux` — the one
extra that does pull `torch` (583 MB of it, for mflux's utility stack; MLX
still runs every model). Measured on macOS arm64 / Python 3.13 with mlx-lm 0.32,
mlx-vlm 0.7, mlx-audio 0.5.7, and mflux 0.21.

### Run

Expand All @@ -104,6 +108,9 @@ vllm-omni-mlx serve mlx-community/Qwen3-Omni-30B-A3B-Instruct-4bit \

# speech-only server
vllm-omni-mlx serve mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-4bit --omni

# image-only server (Z-Image-Turbo, 4-bit ~5.5 GiB of weights)
vllm-omni-mlx serve --image-model z-image-turbo # needs [image]
```

Qwen3-Omni serves text-out chat today (speech-out chat is in progress); the 30B-A3B
Expand Down
27 changes: 27 additions & 0 deletions docs/adding-a-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,30 @@ Apple-Silicon budget, not the ceiling.
- [ ] Numbers in the PR: TTFA p50/p95, RTF distribution, peak memory —
solo *and* with a chat model resident
- [ ] HNR gate calibrated per voice, human audition done

## Image models (`diffusion/`, #91)

The seam is adapter-shaped, not loop-shaped — no streaming, one blocking
call per request:

1. **Family registry** (`diffusion/config.py`): add a `Family` (aliases,
canonical repo, default steps, `supports_guidance`) and route refs in
`resolve_model()` → `ResolvedModel(family, weights_ref, quantize)`.
Weight routing matters: pre-quantized mirror repos honor their stored
level (`quantize=None`); canonical fp16 repos quantize on load
(`quantize=4`). Keep `config.py` importable without the `[image]`
extra (CI installs none).
2. **Service** (`diffusion/mflux_service.py`): `MFluxService` maps the
request onto the mflux backend call, validates family capabilities
(guidance on a guidance-distilled model → `ValueError`, the route
maps it to a 400), runs load+generate on one dedicated worker thread
(MLX stream affinity — serve smoke is mandatory, see #71), and
returns `ImageResult` (PNG bytes + seed + timings + peak
memory) — the metrics the doctrine reports.
3. **Tests** (`tests/diffusion/`, mirroring src): CI-safe resolution +
endpoint tests with a fake service; one weight-gated file per model
(per-file subprocess, offline, donor release in `tearDownClass`).
Gates: valid PNG above a size floor, dimensions/steps honored,
**same seed → byte-identical PNG** (characterize, don't assume, if
fusion flips appear), family-capability 400s.
4. **Docs**: README models-table row + the `[image]` footprint row.
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ Changelog = "https://github.com/ThinkFlowLab/vllm-omni-mlx/releases"
[project.optional-dependencies]
omni = ["mlx-vlm>=0.7"]
tts = ["mlx-audio>=0.5.7,<0.6"]
# minor-capped like mlx-audio: the mflux seam touches mflux.models.* internals
# (loader classes), so a minor bump is an adaptation PR, not a free upgrade
image = ["mflux>=0.21.0,<0.22"]

[project.scripts]
# serve/tts subcommands, upstream vllm-omni's `vllm serve <model> --omni` shape
vllm-omni-mlx = "vllm_omni_mlx.__main__:main"

[tool.setuptools]
packages = ["vllm_omni_mlx", "vllm_omni_mlx.tts"]
packages = ["vllm_omni_mlx", "vllm_omni_mlx.tts", "vllm_omni_mlx.diffusion"]
Empty file added tests/diffusion/__init__.py
Empty file.
79 changes: 79 additions & 0 deletions tests/diffusion/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Family registry + request parsing for /v1/images/generations (#91).

CI-safe: no mflux import (config.py keeps backend imports lazy) and no
weights — resolution and defaults only.
"""

import unittest

from vllm_omni_mlx.diffusion.config import DEFAULT_MODEL, FAMILIES, resolve_model
from vllm_omni_mlx.schemas import ApiError, parse_image_generation


class ResolveModelTest(unittest.TestCase):
def test_alias_routes_to_prequant_default(self):
for alias in ("z-image-turbo", "ZImage-Turbo"):
resolved = resolve_model(alias)
self.assertEqual(resolved.family.name, "z-image-turbo")
self.assertEqual(resolved.weights_ref, DEFAULT_MODEL)
self.assertIsNone(resolved.quantize) # stored 4-bit level honored

def test_canonical_repo_quantizes_on_load(self):
resolved = resolve_model("Tongyi-MAI/Z-Image-Turbo")
self.assertEqual(resolved.weights_ref, "Tongyi-MAI/Z-Image-Turbo")
self.assertEqual(resolved.quantize, 4)

def test_prequant_mirror(self):
resolved = resolve_model(DEFAULT_MODEL)
self.assertEqual(resolved.weights_ref, DEFAULT_MODEL)
self.assertIsNone(resolved.quantize)

def test_unknown_ref_lists_supported(self):
with self.assertRaises(ValueError) as ctx:
resolve_model("stabilityai/stable-diffusion-xl-base-1.0")
self.assertIn("z-image-turbo", str(ctx.exception))

def test_every_family_has_serving_defaults(self):
for family in FAMILIES:
self.assertGreaterEqual(family.steps, 1, family.name)


class ParseImageGenerationTest(unittest.TestCase):
def test_minimal_request(self):
req = parse_image_generation({"prompt": "a cat"})
self.assertEqual((req.width, req.height, req.n, req.steps), (1024, 1024, 1, None))

def test_size_and_diffusion_params(self):
req = parse_image_generation(
{"prompt": "a cat", "n": 2, "size": "768X1344", "steps": 9, "guidance": 0.0, "seed": 7}
)
self.assertEqual((req.width, req.height, req.n, req.steps, req.guidance, req.seed), (768, 1344, 2, 9, 0.0, 7))

def test_prompt_must_be_nonempty_string(self):
for bad in (None, 5, "", " "):
with self.assertRaises(ApiError):
parse_image_generation({"prompt": bad})

def test_size_format(self):
for bad in ("1024", "ax1024", "1024x", 1024, "1024.5x1024"):
with self.assertRaises(ApiError):
parse_image_generation({"prompt": "a cat", "size": bad})

def test_dimension_bounds_and_grid(self):
for bad in ("240x1024", "2112x1024", "1024x1000"): # below/above bounds, not %16
with self.assertRaises(ApiError):
parse_image_generation({"prompt": "a cat", "size": bad})

def test_n_range(self):
for bad in (0, 5, "2", True):
with self.assertRaises(ApiError):
parse_image_generation({"prompt": "a cat", "n": bad})

def test_scalar_types(self):
for payload in ({"prompt": "a cat", "steps": "9"}, {"prompt": "a cat", "guidance": "0"}, {"prompt": "a cat", "seed": 1.5}):
with self.assertRaises(ApiError):
parse_image_generation(payload)


if __name__ == "__main__":
unittest.main()
97 changes: 97 additions & 0 deletions tests/diffusion/test_images_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""POST /v1/images/generations (#91): validation, auth, model listing,
and the b64/seeds response shape with a fake service. The real
Z-Image-Turbo round-trip is weight-gated in test_mflux_zimage.py."""

import base64
import unittest

from starlette.testclient import TestClient

from vllm_omni_mlx.diffusion.service import ImageResult
from vllm_omni_mlx.server import create_app


class FakeImageService:
name = "fake-image-model"
model_type = "image"

def __init__(self):
self.calls = []

def generate(self, prompt, n=1, width=1024, height=1024, steps=None, guidance=None, seed=None):
if not prompt or not prompt.strip():
raise ValueError("prompt must be a non-empty string")
if not 1 <= n <= 4:
raise ValueError(f"n must be between 1 and 4, got {n}")
if guidance is not None:
raise ValueError("guidance is not supported on z-image-turbo (guidance-distilled)")
self.calls.append((prompt, n, width, height, steps, guidance, seed))
return [
ImageResult(
png=b"\x89PNG-fake-" + bytes([i]),
seed=(seed if seed is not None else 1234) + i,
width=width,
height=height,
steps=steps or 9,
generation_time=0.5,
peak_memory_gib=1.0,
)
for i in range(n)
]


class ImagesEndpointTest(unittest.TestCase):
def setUp(self):
self.service = FakeImageService()
self.client = TestClient(create_app(image_service=self.service))

def test_round_trip(self):
response = self.client.post(
"/v1/images/generations", json={"prompt": "a cat", "size": "512x512", "steps": 9, "seed": 42}
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["model"], "fake-image-model")
self.assertEqual(len(body["data"]), 1)
self.assertEqual(base64.b64decode(body["data"][0]["b64_json"]), b"\x89PNG-fake-\x00")
self.assertEqual(body["seeds"], [42])
self.assertEqual(body["generation_time_ms"], [500])
self.assertEqual(self.service.calls, [("a cat", 1, 512, 512, 9, None, 42)])

def test_n_sequential_seeds(self):
body = self.client.post("/v1/images/generations", json={"prompt": "a cat", "n": 3, "seed": 10}).json()
self.assertEqual(body["seeds"], [10, 11, 12])
self.assertEqual(len(body["data"]), 3)

def test_validation_errors_are_400(self):
for payload in ({"prompt": ""}, {"prompt": "a cat", "size": "1000x1024"}, {"prompt": "a cat", "n": 9}):
response = self.client.post("/v1/images/generations", json=payload)
self.assertEqual(response.status_code, 400, payload)
self.assertEqual(response.json()["error"]["type"], "invalid_request_error")

def test_family_capability_error_is_400(self):
response = self.client.post("/v1/images/generations", json={"prompt": "a cat", "guidance": 3.5})
self.assertEqual(response.status_code, 400)
self.assertIn("guidance-distilled", response.json()["error"]["message"])

def test_auth(self):
client = TestClient(create_app(image_service=self.service, api_key="secret"))
self.assertEqual(client.post("/v1/images/generations", json={"prompt": "a cat"}).status_code, 401)
self.assertEqual(
client.post(
"/v1/images/generations", json={"prompt": "a cat"}, headers={"Authorization": "Bearer secret"}
).status_code,
200,
)

def test_route_absent_without_service(self):
client = TestClient(create_app())
self.assertEqual(client.post("/v1/images/generations", json={"prompt": "a cat"}).status_code, 404)

def test_model_listing(self):
body = self.client.get("/v1/models").json()
self.assertIn("fake-image-model", [entry["id"] for entry in body["data"]])


if __name__ == "__main__":
unittest.main()
77 changes: 77 additions & 0 deletions tests/diffusion/test_mflux_zimage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Real Z-Image-Turbo round-trip through the mflux seam (#99).

Weight-gated: runs against the local HF snapshot when cached and skips
elsewhere (CI has no weights, so a green CI run does not exercise this
file). One file = one process (CONTRIBUTING) — the 4-bit model peaks
around 6–7 GiB resident and holds it for the class.
"""

import os

# offline keeps the load fast and hang-free when the network is flaky
os.environ.setdefault("HF_HUB_OFFLINE", "1")

import unittest

from vllm_omni_mlx.diffusion.config import DEFAULT_MODEL, load_image_service


def _snapshot_cached() -> bool:
# direct check rather than snapshot_download(local_files_only=True):
# a patterns-filtered download counts as "incomplete" to the hub API
# even when every weight file is present
import glob
import os

pattern = os.path.expanduser(
f"~/.cache/huggingface/hub/models--{DEFAULT_MODEL.replace('/', '--')}/snapshots/*/transformer/*.safetensors"
)
return bool(glob.glob(pattern))


@unittest.skipUnless(_snapshot_cached(), f"{DEFAULT_MODEL} not in the local HF cache")
class ZImageTurboE2ETest(unittest.TestCase):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Weight-gated class skips the ReleaseAfterClass taxonomy. Every weight-gated class in the repo inherits tests/_teardown.ReleaseAfterClass (16 TTS classes in this tree; tests/test_asr.py:286 on main). The hand-rolled tearDownClass is functionally equivalent today but diverges from the documented mechanism and won't pick up future ReleaseAfterClass additions. Fix: class ZImageTurboE2ETest(ReleaseAfterClass, unittest.TestCase): and drop the custom tearDownClass.

"""512² × 9 steps keeps the run quick while exercising the full path:
pre-quant load → text encode → 9 denoise steps → VAE decode → PNG."""

@classmethod
def setUpClass(cls):
import mlx.core as mx

cls.mx = mx
cls.service = load_image_service(DEFAULT_MODEL)
cls.prompt = "a crisp red apple on a wooden table, studio light"

@classmethod
def tearDownClass(cls):
# donor release: one heavy thing at a time on a 16 GB machine
cls.service = None
cls.mx.clear_cache()

def test_generate_png_shape_and_gate(self):
(result,) = self.service.generate(self.prompt, width=512, height=512, steps=9, seed=20261007)
self.assertTrue(result.png.startswith(b"\x89PNG"), "output must be a PNG")
self.assertGreater(len(result.png), 50_000, "a 512² PNG should not be tiny (non-degenerate gate)")
self.assertEqual((result.width, result.height, result.steps), (512, 512, 9))
self.assertEqual(result.seed, 20261007)
self.assertGreater(result.generation_time, 0.1)
print(
f"\nz-image-turbo 512x512/9 steps: {result.generation_time:.2f}s, "
f"peak {result.peak_memory_gib:.2f} GiB, png {len(result.png) / 1e6:.2f} MB",
flush=True,
)

def test_same_seed_reproduces_bytes(self):
first = self.service.generate(self.prompt, width=512, height=512, steps=9, seed=7)[0].png
second = self.service.generate(self.prompt, width=512, height=512, steps=9, seed=7)[0].png
self.assertEqual(
first, second, "same-seed calls must reproduce byte-identical PNGs (fp16 fusion-flip precedent: characterize if this trips)"
)

def test_family_capability_validation(self):
with self.assertRaises(ValueError):
self.service.generate(self.prompt, width=512, height=512, guidance=3.5)


if __name__ == "__main__":
unittest.main()
Loading
Loading