Round 13 reviewer aggregate (logs/review_round13_aggregate.md): P1 fixes: - routes/export.py load_checkpoint refuses (409) when an export job is currently active, mirroring the chat/diffusion/training handoff guards. ``is_export_active`` absence is tolerated for older / mocked backends. - core/inference/diffusion.py local-path GGUF loader now accepts relative directories (Studio exports surface as ``exports/my-flux``) and confines ``gguf_filename`` to the chosen repo via ``_resolve_local_gguf_child``: absolute filenames, ``..`` segments, and Windows separators are rejected before any file is opened. - core/inference/diffusion.py status() exposes ``active_gguf_filename`` alongside the pending variant so delete guards can pair each owned repo with the GGUF variant it actually owns. - routes/models.py cache delete + finetuned delete adopt a shared ``_diffusion_owned_targets`` + ``_variant_delete_is_safe_for_owned_gguf`` helper. Per-variant deletes during a swap-in-flight cannot remove the active variant while the pending variant is loading. - core/inference/llama_cpp.py publishes ``loading_model_identifier`` before ``_download_gguf`` starts and clears it in ``finally``. Cache delete (routes/models.py) and the cross-workload release helpers (routes/inference.py::_release_llama_for and diffusion.py::_release_chat_backend_for_diffusion) consult it so a multi-GB HF download cannot be rmtree'd or be ignored by /images/load while still in flight. P2 fixes: - core/inference/diffusion.py adds ``generate_image_with_metadata`` + ``async_generate_with_metadata``; /images/generate uses it so the response model/family reflect the pipeline that actually produced the image even if an unload races the route. - core/inference/diffusion.py: ``base_repo`` only applies when picking a GGUF quant. Filling Base diffusers repo while loading a full diffusers repo no longer silently swaps the load target. - core/inference/diffusion.py: failed device placement / offload now drops pipe + transformer references explicitly before drain so partial allocations cannot keep VRAM around. - core/inference/diffusion.py: torch/diffusers imports surface as a clear RuntimeError naming the missing dependency. - core/inference/diffusion.py: _smart_base_repo splits on both POSIX and Windows separators so ``C:\\Users\\me\\base\\FLUX.2-klein-4B-GGUF`` no longer picks the Base 4B variant via the parent dir. Tests: - 6 new regression cases (Windows leaf, traversal/backslash rejection, relative-dir local load, metadata snapshot, lock serialisation). - All 59 diffusion backend + route tests pass.
297 lines
9.7 KiB
Python
297 lines
9.7 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""Route-level tests for ``/api/inference/images/*``.
|
|
|
|
Mounts the actual ``inference_router`` on a fresh FastAPI app with the
|
|
auth dependency replaced by a stub so we exercise the same FastAPI
|
|
handlers Studio ships in production. The diffusion backend is replaced
|
|
with an in-memory stub so we don't need diffusers / GPUs to run these.
|
|
|
|
To stay runnable in a minimal CPU-only env, ``routes/inference.py``
|
|
is loaded directly via ``importlib`` so we do NOT trigger
|
|
``routes/__init__.py`` -- that file eagerly imports training /
|
|
datasets / data_recipe / export and would drag in heavy deps
|
|
(matplotlib, etc.) that the diffusion tests do not need.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from PIL import Image
|
|
|
|
|
|
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(_BACKEND_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_BACKEND_ROOT))
|
|
|
|
|
|
def _import_inference_module():
|
|
"""Load ``routes/inference.py`` without executing ``routes/__init__``.
|
|
|
|
The package init imports training / datasets / data_recipe / export
|
|
routers, which pull in matplotlib / pandas / training stack. The
|
|
diffusion tests only need the inference module so we side-step the
|
|
package import via importlib.spec_from_file_location.
|
|
"""
|
|
# If a previous test already imported routes the normal way, reuse
|
|
# the cached module instead of re-loading.
|
|
cached = sys.modules.get("routes.inference")
|
|
if cached is not None:
|
|
return cached
|
|
target = _BACKEND_ROOT / "routes" / "inference.py"
|
|
spec = importlib.util.spec_from_file_location(
|
|
"routes.inference",
|
|
target,
|
|
# We do NOT set submodule_search_locations for routes itself
|
|
# because that would re-trigger routes/__init__.py. The module
|
|
# uses relative imports sparingly; absolute imports resolve via
|
|
# sys.path[0] = backend root.
|
|
)
|
|
assert spec and spec.loader, "could not build spec for routes/inference.py"
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules["routes.inference"] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class _FakeBackend:
|
|
def __init__(self) -> None:
|
|
self._loaded = False
|
|
self._repo: str | None = None
|
|
self.calls: list[dict] = []
|
|
|
|
@property
|
|
def is_loaded(self) -> bool:
|
|
return self._loaded
|
|
|
|
def status(self) -> dict:
|
|
return {
|
|
"is_loaded": self._loaded,
|
|
"is_loading": False,
|
|
"repo_id": self._repo,
|
|
"family": "flux.2-klein" if self._loaded else None,
|
|
"pipeline_class": "Flux2KleinPipeline" if self._loaded else None,
|
|
"base_repo": "black-forest-labs/FLUX.2-klein" if self._loaded else None,
|
|
"gguf_filename": None,
|
|
"active_repo_id": self._repo,
|
|
"active_base_repo": (
|
|
"black-forest-labs/FLUX.2-klein" if self._loaded else None
|
|
),
|
|
"active_gguf_filename": None,
|
|
"pending_repo_id": None,
|
|
"pending_base_repo": None,
|
|
"pending_gguf_filename": None,
|
|
"device": "cpu",
|
|
"dtype": "torch.bfloat16",
|
|
"loaded_at": 0,
|
|
"last_error": None,
|
|
"supported_families": [],
|
|
}
|
|
|
|
def load_model(self, repo_id, **kw):
|
|
self.calls.append({"op": "load", "repo_id": repo_id, **kw})
|
|
self._loaded = True
|
|
self._repo = repo_id
|
|
return self.status()
|
|
|
|
def unload_model(self) -> dict:
|
|
self._loaded = False
|
|
self._repo = None
|
|
return {"is_loaded": False}
|
|
|
|
def generate_image(self, **kw):
|
|
self.calls.append({"op": "generate", **kw})
|
|
return Image.new("RGB", (kw["width"], kw["height"]), color = (123, 45, 67))
|
|
|
|
def generate_image_with_metadata(self, **kw):
|
|
image = self.generate_image(**kw)
|
|
meta = {
|
|
"model": self._repo,
|
|
"family": "flux.2-klein" if self._loaded else None,
|
|
}
|
|
return image, meta
|
|
|
|
|
|
@pytest.fixture
|
|
def app_with_stub(monkeypatch):
|
|
"""Build a FastAPI app that mounts the real inference router with
|
|
auth disabled and the diffusion backend swapped for a stub."""
|
|
inf = _import_inference_module()
|
|
import core.inference.diffusion as d
|
|
|
|
stub = _FakeBackend()
|
|
# Override the singleton accessor the route uses.
|
|
monkeypatch.setattr(d, "get_diffusion_backend", lambda: stub)
|
|
monkeypatch.setattr(inf, "_get_diffusion_backend", lambda: stub)
|
|
|
|
app = FastAPI()
|
|
# Diffusion image routes live on studio_router so they are NOT
|
|
# exposed under /v1 (which would let OpenAI-compat clients
|
|
# trigger Studio-only side effects).
|
|
app.include_router(inf.router, prefix = "/api/inference")
|
|
app.include_router(inf.studio_router, prefix = "/api/inference")
|
|
# Bypass auth by overriding the dependency.
|
|
from auth.authentication import get_current_subject
|
|
|
|
app.dependency_overrides[get_current_subject] = lambda: "test-user"
|
|
|
|
return app, stub
|
|
|
|
|
|
def test_status_when_unloaded(app_with_stub):
|
|
app, _ = app_with_stub
|
|
c = TestClient(app)
|
|
r = c.get("/api/inference/images/status")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["is_loaded"] is False
|
|
assert body["repo_id"] is None
|
|
|
|
|
|
def test_generate_without_load_returns_400(app_with_stub):
|
|
app, _ = app_with_stub
|
|
c = TestClient(app)
|
|
r = c.post(
|
|
"/api/inference/images/generate",
|
|
json = {"prompt": "a red sphere"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "No diffusion model" in r.json()["detail"]
|
|
|
|
|
|
def test_load_then_generate_round_trip(app_with_stub):
|
|
app, stub = app_with_stub
|
|
c = TestClient(app)
|
|
|
|
r = c.post(
|
|
"/api/inference/images/load",
|
|
json = {
|
|
"repo_id": "unsloth/FLUX.2-klein-4B-GGUF",
|
|
"gguf_filename": "flux-2-klein-4b-Q4_K_S.gguf",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["is_loaded"] is True
|
|
|
|
r = c.post(
|
|
"/api/inference/images/generate",
|
|
json = {
|
|
"prompt": "a tiny synth-pop album cover",
|
|
"width": 256,
|
|
"height": 256,
|
|
"num_inference_steps": 4,
|
|
"seed": 7,
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["image_b64"]
|
|
assert body["image_mime"] == "image/png"
|
|
assert body["width"] == 256
|
|
assert body["height"] == 256
|
|
assert body["seed"] == 7
|
|
assert body["duration_ms"] >= 0
|
|
|
|
# Round-trip the base64 -> PIL to confirm it is a real PNG of the
|
|
# right size and not, say, an empty string.
|
|
import base64
|
|
import io
|
|
|
|
raw = base64.b64decode(body["image_b64"])
|
|
decoded = Image.open(io.BytesIO(raw))
|
|
assert decoded.format == "PNG"
|
|
assert decoded.size == (256, 256)
|
|
|
|
# Backend stub should have recorded both calls.
|
|
ops = [c["op"] for c in stub.calls]
|
|
assert ops == ["load", "generate"]
|
|
|
|
|
|
def test_generate_rejects_off_grid_size(app_with_stub):
|
|
app, stub = app_with_stub
|
|
c = TestClient(app)
|
|
c.post(
|
|
"/api/inference/images/load",
|
|
json = {
|
|
"repo_id": "unsloth/FLUX.2-klein-4B-GGUF",
|
|
"gguf_filename": "x.gguf",
|
|
},
|
|
)
|
|
r = c.post(
|
|
"/api/inference/images/generate",
|
|
json = {"prompt": "x", "width": 513, "height": 512},
|
|
)
|
|
# Pydantic v2 wraps validator errors in 422 by default.
|
|
assert r.status_code in (400, 422), r.text
|
|
|
|
|
|
def test_unload_clears_state(app_with_stub):
|
|
app, _ = app_with_stub
|
|
c = TestClient(app)
|
|
c.post(
|
|
"/api/inference/images/load",
|
|
json = {"repo_id": "unsloth/FLUX.2-klein-4B-GGUF", "gguf_filename": "x.gguf"},
|
|
)
|
|
r = c.post("/api/inference/images/unload")
|
|
assert r.status_code == 200
|
|
assert r.json()["is_loaded"] is False
|
|
r = c.get("/api/inference/images/status")
|
|
assert r.json()["is_loaded"] is False
|
|
|
|
|
|
def test_load_rejects_control_chars_in_repo_id(app_with_stub):
|
|
"""Newline-laden repo ids must be rejected by Pydantic BEFORE the
|
|
log line that echoes them. Catches log-injection from authenticated
|
|
callers (issues a 422 instead of forging a fake log line)."""
|
|
app, _ = app_with_stub
|
|
c = TestClient(app)
|
|
r = c.post(
|
|
"/api/inference/images/load",
|
|
json = {"repo_id": "owner/model\nFAKE_LOG_LINE"},
|
|
)
|
|
assert r.status_code == 422, r.text
|
|
body = r.json()
|
|
text = repr(body).lower()
|
|
assert "control" in text or "repo_id" in text
|
|
|
|
|
|
def test_generate_rejects_oversize_seed(app_with_stub):
|
|
"""Huge seeds raise inside torch.Generator.manual_seed; Pydantic
|
|
must clamp first with a 422 instead of a 500 traceback."""
|
|
app, _ = app_with_stub
|
|
c = TestClient(app)
|
|
c.post(
|
|
"/api/inference/images/load",
|
|
json = {"repo_id": "unsloth/FLUX.2-klein-4B-GGUF", "gguf_filename": "x.gguf"},
|
|
)
|
|
r = c.post(
|
|
"/api/inference/images/generate",
|
|
json = {"prompt": "x", "seed": 2**100},
|
|
)
|
|
assert r.status_code == 422, r.text
|
|
|
|
|
|
def test_generate_accepts_uint64_max_seed(app_with_stub):
|
|
"""Boundary value: 2**64 - 1 (uint64 max) is the largest seed
|
|
torch.Generator on CPU accepts; reject would frustrate users
|
|
who paste large seeds from other tooling."""
|
|
app, _ = app_with_stub
|
|
c = TestClient(app)
|
|
c.post(
|
|
"/api/inference/images/load",
|
|
json = {"repo_id": "unsloth/FLUX.2-klein-4B-GGUF", "gguf_filename": "x.gguf"},
|
|
)
|
|
r = c.post(
|
|
"/api/inference/images/generate",
|
|
json = {"prompt": "x", "seed": (2**64) - 1},
|
|
)
|
|
# The fake backend returns 200 on success; we only care that the
|
|
# request did NOT 422 on seed bounds.
|
|
assert r.status_code != 422, r.text
|