Changes
diff --git a/README.md b/README.md
index dd8a8d3..fd4e076 100644
--- a/README.md
+++ b/README.md
@@ -49,5 +49,24 @@ uv run diffusion-cli --prompt "a ceramic mug" --steps 12
In this example, `--steps 12` overrides any `generation.steps` value in
the config file for that run only.
-Generation is intentionally guarded until the local Z-Image/Lumina2
-model port is implemented.
+## HTTP API server
+
+Start the SillyTavern compatibility API with an explicit profile:
+
+```bash
+uv run diffusion-cli serve \
+ --api-profile sillytavern-sdcpp \
+ --host 127.0.0.1 \
+ --port 7860
+```
+
+Then point SillyTavern's stable-diffusion.cpp provider at:
+
+```text
+http://127.0.0.1:7860
+```
+
+The `sillytavern-sdcpp` profile implements the endpoint mix used by
+SillyTavern's provider adapter. It is not the native stable-diffusion.cpp
+server API. Generation still requires local model paths in
+`~/.config/diffusion.toml`.
diff --git a/diffusion_cli/api_profiles.py b/diffusion_cli/api_profiles.py
new file mode 100644
index 0000000..f25c3c8
--- /dev/null
+++ b/diffusion_cli/api_profiles.py
@@ -0,0 +1,187 @@
+"""HTTP API profile registry and route implementations."""
+
+from __future__ import annotations
+
+from base64 import b64encode
+from dataclasses import dataclass
+import json
+from typing import Callable
+
+from flask import Response, jsonify, request
+
+from diffusion_cli.config import ImageGenerationRequest, validateDimensions
+from diffusion_cli.errors import DiffusionCliError
+
+UNSUPPORTED_IMAGE_FIELDS = ("init_images", "mask", "extra_images")
+
+
+@dataclass(frozen=True)
+class ApiProfile:
+ """HTTP API contract exposed by server mode."""
+
+ name: str
+ description: str
+ register_routes: Callable[[object, object], None]
+
+
+def _optionalString(data: dict, key: str) -> str | None:
+ value = data.get(key)
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ raise DiffusionCliError(f"{key} must be a string")
+ return value
+
+
+def _optionalInt(data: dict, key: str) -> int | None:
+ value = data.get(key)
+ if value is None:
+ return None
+ if not isinstance(value, int) or isinstance(value, bool):
+ raise DiffusionCliError(f"{key} must be an integer")
+ return value
+
+
+def _optionalFloat(data: dict, key: str) -> float | None:
+ value = data.get(key)
+ if value is None:
+ return None
+ if not isinstance(value, int | float) or isinstance(value, bool):
+ raise DiffusionCliError(f"{key} must be a number")
+ return float(value)
+
+
+def _txt2imgRequest(data: dict) -> ImageGenerationRequest:
+ for field in UNSUPPORTED_IMAGE_FIELDS:
+ if field in data:
+ raise DiffusionCliError(f"{field} is not supported")
+
+ prompt = data.get("prompt")
+ if not isinstance(prompt, str) or not prompt.strip():
+ raise DiffusionCliError("prompt required")
+
+ seed = _optionalInt(data, "seed")
+ if seed == -1:
+ seed = None
+ width = _optionalInt(data, "width")
+ height = _optionalInt(data, "height")
+ if width is not None and height is not None:
+ validateDimensions(width, height)
+ elif width is not None and (width <= 0 or width % 8 != 0):
+ raise DiffusionCliError(
+ f"Width must be a positive multiple of 8: got {width}"
+ )
+ elif height is not None and (height <= 0 or height % 8 != 0):
+ raise DiffusionCliError(
+ f"Height must be a positive multiple of 8: got {height}"
+ )
+ steps = _optionalInt(data, "steps")
+ batch_size = _optionalInt(data, "batch_size")
+ cfg = _optionalFloat(data, "cfg_scale")
+ if steps is not None and steps < 1:
+ raise DiffusionCliError(f"Steps must be at least 1: got {steps}")
+ if batch_size is not None and batch_size < 1:
+ raise DiffusionCliError(
+ f"Batch size must be at least 1: got {batch_size}"
+ )
+ if cfg is not None and cfg < 0:
+ raise DiffusionCliError(f"CFG must be non-negative: got {cfg}")
+
+ return ImageGenerationRequest(
+ prompt=prompt,
+ negative_prompt=_optionalString(data, "negative_prompt"),
+ seed=seed,
+ width=width,
+ height=height,
+ batch_size=batch_size,
+ steps=steps,
+ cfg=cfg,
+ )
+
+
+def _badRequest(message: str):
+ return jsonify({"error": "bad_request", "message": message}), 400
+
+
+def _generationFailed(message: str):
+ return jsonify({"error": "generation_failed", "message": message}), 500
+
+
+def registerSillyTavernSdcppRoutes(app, context) -> None:
+ """Register SillyTavern stable-diffusion.cpp compatibility routes."""
+
+ @app.route("/health", methods=["GET"])
+ def health():
+ return jsonify({
+ "status": "ok",
+ "api_profile": context.server_config.api_profile,
+ })
+
+ @app.route("/v1/images/generations", methods=["OPTIONS"])
+ def imageGenerationsOptions():
+ response = Response(status=204)
+ response.headers["Allow"] = "OPTIONS, POST"
+ return response
+
+ @app.route("/v1/models", methods=["GET"])
+ def models():
+ return jsonify({
+ "data": [
+ {
+ "id": "z-image-local",
+ "object": "model",
+ "owned_by": "local",
+ }
+ ]
+ })
+
+ @app.route("/sdapi/v1/txt2img", methods=["POST"])
+ def txt2img():
+ data = request.get_json(silent=True)
+ if not isinstance(data, dict) or not data:
+ return _badRequest("JSON object body required")
+
+ try:
+ generation_request = _txt2imgRequest(data)
+ except DiffusionCliError as exc:
+ return _badRequest(str(exc))
+
+ try:
+ images = context.generation_service.generateImages(
+ generation_request,
+ )
+ except DiffusionCliError as exc:
+ app.logger.exception("Generation failed")
+ return _generationFailed(str(exc))
+ except Exception:
+ app.logger.exception("Generation failed")
+ return _generationFailed("generation failed")
+
+ encoded_images = [
+ b64encode(image.data).decode("ascii") for image in images
+ ]
+ info = {
+ "prompt": generation_request.prompt,
+ "negative_prompt": generation_request.negative_prompt,
+ "seed": images[0].seed if images else generation_request.seed,
+ "width": generation_request.width,
+ "height": generation_request.height,
+ "steps": generation_request.steps,
+ "cfg_scale": generation_request.cfg,
+ }
+ return jsonify({
+ "images": encoded_images,
+ "parameters": data,
+ "info": json.dumps(info, separators=(",", ":")),
+ })
+
+
+SILLYTAVERN_SDCPP_PROFILE = ApiProfile(
+ name="sillytavern-sdcpp",
+ description="SillyTavern stable-diffusion.cpp adapter compatibility.",
+ register_routes=registerSillyTavernSdcppRoutes,
+)
+
+API_PROFILES = {
+ SILLYTAVERN_SDCPP_PROFILE.name: SILLYTAVERN_SDCPP_PROFILE,
+}
diff --git a/diffusion_cli/cli.py b/diffusion_cli/cli.py
index 88c9b15..69e11b3 100644
--- a/diffusion_cli/cli.py
+++ b/diffusion_cli/cli.py
@@ -8,6 +8,7 @@ from pathlib import Path
import sys
from diffusion_cli.checkpoint import inspectSourceTorchDtype
+from diffusion_cli.api_profiles import API_PROFILES
from diffusion_cli.config import (
UserConfig,
buildGenerationConfig,
@@ -18,6 +19,7 @@ from diffusion_cli.image_io import saveImages
from diffusion_cli.model_inspect import formatSummary, inspectModelSource
from diffusion_cli.paths import resolveModelSources
from diffusion_cli.sampling import sampleLatents
+from diffusion_cli.server import serve, validateServerConfig
from diffusion_cli.text_encoder import ZImageTextEncoder
from diffusion_cli.vae import ZImageVae
from diffusion_cli.zimage_model import ZImageModel
@@ -77,6 +79,34 @@ def buildParser() -> argparse.ArgumentParser:
action="store_true",
help="Inspect local safetensors metadata and exit.",
)
+ subparsers = parser.add_subparsers(dest="command")
+ serve_parser = subparsers.add_parser(
+ "serve",
+ help="Start a long-running HTTP API server.",
+ )
+ serve_parser.add_argument(
+ "--api-profile",
+ required=True,
+ choices=tuple(API_PROFILES),
+ help="HTTP API profile to expose.",
+ )
+ serve_parser.add_argument(
+ "--host",
+ default="127.0.0.1",
+ help="Host interface to bind.",
+ )
+ serve_parser.add_argument(
+ "--port",
+ default=7860,
+ type=int,
+ help="TCP port to bind.",
+ )
+ serve_parser.add_argument(
+ "--model-residency",
+ default="cpu-cache",
+ choices=("staged", "cpu-cache"),
+ help="How server mode keeps model components resident.",
+ )
return parser
@@ -171,6 +201,16 @@ def main(argv: list[str] | None = None) -> int:
try:
user_config = loadUserConfig()
+ if args.command == "serve":
+ server_config = validateServerConfig(
+ args.api_profile,
+ args.host,
+ args.port,
+ args.model_residency,
+ )
+ serve(server_config, user_config)
+ return 0
+
if args.inspect_models:
inspectModels(args, user_config)
return 0
diff --git a/diffusion_cli/config.py b/diffusion_cli/config.py
index df657a6..42b4cce 100644
--- a/diffusion_cli/config.py
+++ b/diffusion_cli/config.py
@@ -132,6 +132,24 @@ class GenerationConfig:
tokenizer_path: Path
+@dataclass(frozen=True)
+class ImageGenerationRequest:
+ """Validated user intent for one text-to-image request."""
+
+ prompt: str
+ negative_prompt: str | None = None
+ seed: int | None = None
+ width: int | None = None
+ height: int | None = None
+ batch_size: int | None = None
+ steps: int | None = None
+ cfg: float | None = None
+ output: Path | None = None
+ device: str | None = None
+ dtype: str | None = None
+ tokenizer_path: Path | None = None
+
+
def randomSeed() -> int:
"""Return a random 64-bit seed suitable for torch generators."""
@@ -379,38 +397,38 @@ def selectDtype(dtype_name: str, device) -> object:
raise AssertionError("unreachable dtype branch")
-def buildGenerationConfig(
- args,
+def buildGenerationConfigFromRequest(
+ request: ImageGenerationRequest,
user_config: UserConfig | None = None,
) -> GenerationConfig:
- """Validate parsed CLI arguments and build a generation config."""
+ """Validate an internal request and build a generation config."""
if user_config is None:
user_config = loadUserConfig()
generation = user_config.generation
models = user_config.models
- if not args.prompt:
+ if not request.prompt:
raise DiffusionCliError("--prompt is required for generation")
negative_prompt = coalesce(
- args.negative_prompt,
+ request.negative_prompt,
generation.negative_prompt,
DEFAULT_NEGATIVE_PROMPT,
)
- width = coalesce(args.width, generation.width, DEFAULT_WIDTH)
- height = coalesce(args.height, generation.height, DEFAULT_HEIGHT)
+ width = coalesce(request.width, generation.width, DEFAULT_WIDTH)
+ height = coalesce(request.height, generation.height, DEFAULT_HEIGHT)
batch_size = coalesce(
- args.batch_size,
+ request.batch_size,
generation.batch_size,
DEFAULT_BATCH_SIZE,
)
- steps = coalesce(args.steps, generation.steps, DEFAULT_STEPS)
- cfg = coalesce(args.cfg, generation.cfg, DEFAULT_CFG)
- device_name = coalesce(args.device, generation.device, DEFAULT_DEVICE)
- dtype_name = coalesce(args.dtype, generation.dtype, DEFAULT_DTYPE)
- output_path = coalesce(args.output, generation.output, DEFAULT_OUTPUT)
- tokenizer_path = args.tokenizer_path
+ steps = coalesce(request.steps, generation.steps, DEFAULT_STEPS)
+ cfg = coalesce(request.cfg, generation.cfg, DEFAULT_CFG)
+ device_name = coalesce(request.device, generation.device, DEFAULT_DEVICE)
+ dtype_name = coalesce(request.dtype, generation.dtype, DEFAULT_DTYPE)
+ output_path = coalesce(request.output, generation.output, DEFAULT_OUTPUT)
+ tokenizer_path = request.tokenizer_path
if tokenizer_path is None:
tokenizer_path = models.tokenizer
@@ -431,10 +449,10 @@ def buildGenerationConfig(
dtype = selectDtype(dtype_name, device)
output = validateOutputPath(output_path)
tokenizer_path = validateTokenizerPath(tokenizer_path)
- seed = args.seed if args.seed is not None else randomSeed()
+ seed = request.seed if request.seed is not None else randomSeed()
return GenerationConfig(
- prompt=args.prompt,
+ prompt=request.prompt,
negative_prompt=negative_prompt,
seed=seed,
width=width,
@@ -448,3 +466,28 @@ def buildGenerationConfig(
output=output,
tokenizer_path=tokenizer_path,
)
+
+
+def buildGenerationConfig(
+ args,
+ user_config: UserConfig | None = None,
+) -> GenerationConfig:
+ """Validate parsed CLI arguments and build a generation config."""
+
+ return buildGenerationConfigFromRequest(
+ ImageGenerationRequest(
+ prompt=args.prompt,
+ negative_prompt=args.negative_prompt,
+ seed=args.seed,
+ width=args.width,
+ height=args.height,
+ batch_size=args.batch_size,
+ steps=args.steps,
+ cfg=args.cfg,
+ output=args.output,
+ device=args.device,
+ dtype=args.dtype,
+ tokenizer_path=args.tokenizer_path,
+ ),
+ user_config,
+ )
diff --git a/diffusion_cli/generation_service.py b/diffusion_cli/generation_service.py
new file mode 100644
index 0000000..8f0fefd
--- /dev/null
+++ b/diffusion_cli/generation_service.py
@@ -0,0 +1,212 @@
+"""HTTP-independent generation service and residency management."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import gc
+from pathlib import Path
+from threading import Lock
+
+from diffusion_cli.checkpoint import inspectSourceTorchDtype
+from diffusion_cli.config import (
+ GenerationConfig,
+ ImageGenerationRequest,
+ UserConfig,
+ buildGenerationConfigFromRequest,
+)
+from diffusion_cli.image_io import encodeImages, saveImages
+from diffusion_cli.paths import resolveModelSourcesFromConfig
+from diffusion_cli.sampling import sampleLatents
+from diffusion_cli.text_encoder import ZImageTextEncoder
+from diffusion_cli.vae import ZImageVae
+from diffusion_cli.zimage_model import ZImageModel
+
+MODEL_RESIDENCY_VALUES = ("staged", "cpu-cache")
+
+
+@dataclass(frozen=True)
+class GeneratedImage:
+ """One generated image encoded for transport or file output."""
+
+ data: bytes
+ format: str
+ seed: int
+
+
+def releaseMemory() -> None:
+ """Release Python and CUDA caches between large model stages."""
+
+ gc.collect()
+ try:
+ import torch
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ except ImportError:
+ pass
+
+
+def componentDtype(config: GenerationConfig, source):
+ """Return the runtime dtype for one component source."""
+
+ if config.dtype_name != "auto":
+ return config.dtype
+ return inspectSourceTorchDtype(source) or config.dtype
+
+
+class GenerationService:
+ """Generate images from internal requests with a residency policy."""
+
+ def __init__(
+ self,
+ user_config: UserConfig,
+ *,
+ model_residency: str = "staged",
+ ) -> None:
+ if model_residency not in MODEL_RESIDENCY_VALUES:
+ raise ValueError(f"Unknown model residency: {model_residency}")
+ self.user_config = user_config
+ self.model_residency = model_residency
+ self._lock = Lock()
+ self._text_encoder: ZImageTextEncoder | None = None
+ self._model: ZImageModel | None = None
+ self._vae: ZImageVae | None = None
+
+ def generateImages(
+ self,
+ request: ImageGenerationRequest,
+ ) -> list[GeneratedImage]:
+ """Generate PNG image bytes for one request."""
+
+ with self._lock:
+ config = buildGenerationConfigFromRequest(
+ request,
+ self.user_config,
+ )
+ images = self._generateTensor(config)
+ encoded_images = encodeImages(images, "png")
+ return [
+ GeneratedImage(data=data, format="png", seed=config.seed)
+ for data in encoded_images
+ ]
+
+ def generateToFiles(self, request: ImageGenerationRequest) -> list[Path]:
+ """Generate images and write them to the configured output path."""
+
+ with self._lock:
+ config = buildGenerationConfigFromRequest(
+ request,
+ self.user_config,
+ )
+ images = self._generateTensor(config)
+ return saveImages(images, config.output)
+
+ def _generateTensor(self, config: GenerationConfig):
+ if self.model_residency == "cpu-cache":
+ return self._generateCpuCache(config)
+ return self._generateStaged(config)
+
+ def _generateStaged(self, config: GenerationConfig):
+ model_sources = resolveModelSourcesFromConfig(self.user_config.models)
+ text_dtype = componentDtype(config, model_sources.text_encoder)
+ diffusion_dtype = componentDtype(config, model_sources.diffusion_model)
+ vae_dtype = componentDtype(config, model_sources.vae)
+
+ text_encoder = ZImageTextEncoder(
+ model_sources.text_encoder,
+ config.tokenizer_path,
+ config.device,
+ text_dtype,
+ )
+ conditioning = text_encoder.encodePrompts(
+ config.prompt,
+ config.negative_prompt,
+ )
+ del text_encoder
+ releaseMemory()
+
+ model = ZImageModel(
+ model_sources.diffusion_model,
+ config.device,
+ diffusion_dtype,
+ )
+ latent = sampleLatents(
+ model,
+ conditioning,
+ batch_size=config.batch_size,
+ height=config.height,
+ width=config.width,
+ seed=config.seed,
+ steps=config.steps,
+ cfg=config.cfg,
+ device=config.device,
+ dtype=diffusion_dtype,
+ )
+ del model
+ releaseMemory()
+
+ vae = ZImageVae(model_sources.vae, config.device, vae_dtype)
+ images = vae.decode(latent)
+ del vae
+ releaseMemory()
+ return images
+
+ def _generateCpuCache(self, config: GenerationConfig):
+ import torch
+
+ model_sources = resolveModelSourcesFromConfig(self.user_config.models)
+ text_dtype = componentDtype(config, model_sources.text_encoder)
+ diffusion_dtype = componentDtype(config, model_sources.diffusion_model)
+ vae_dtype = componentDtype(config, model_sources.vae)
+ cpu = torch.device("cpu")
+
+ if self._text_encoder is None:
+ self._text_encoder = ZImageTextEncoder(
+ model_sources.text_encoder,
+ config.tokenizer_path,
+ cpu,
+ text_dtype,
+ )
+ self._text_encoder.toDevice(config.device, text_dtype)
+ try:
+ conditioning = self._text_encoder.encodePrompts(
+ config.prompt,
+ config.negative_prompt,
+ )
+ finally:
+ self._text_encoder.toCpu()
+ releaseMemory()
+
+ if self._model is None:
+ self._model = ZImageModel(
+ model_sources.diffusion_model,
+ cpu,
+ diffusion_dtype,
+ )
+ self._model.toDevice(config.device, diffusion_dtype)
+ try:
+ latent = sampleLatents(
+ self._model,
+ conditioning,
+ batch_size=config.batch_size,
+ height=config.height,
+ width=config.width,
+ seed=config.seed,
+ steps=config.steps,
+ cfg=config.cfg,
+ device=config.device,
+ dtype=diffusion_dtype,
+ )
+ finally:
+ self._model.toCpu()
+ releaseMemory()
+
+ if self._vae is None:
+ self._vae = ZImageVae(model_sources.vae, cpu, vae_dtype)
+ self._vae.toDevice(config.device, vae_dtype)
+ try:
+ images = self._vae.decode(latent)
+ finally:
+ self._vae.toCpu()
+ releaseMemory()
+ return images
diff --git a/diffusion_cli/image_io.py b/diffusion_cli/image_io.py
index e662dcd..4b24d7f 100644
--- a/diffusion_cli/image_io.py
+++ b/diffusion_cli/image_io.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from io import BytesIO
from pathlib import Path
import numpy as np
@@ -23,13 +24,24 @@ def outputPaths(output: Path, batch_size: int) -> list[Path]:
def saveImages(images: torch.Tensor, output: Path) -> list[Path]:
"""Save an NCHW image tensor in [0, 1] as PNG files."""
+ image_arrays = imageArrays(images)
+ paths = outputPaths(output, len(image_arrays))
+
+ for image_array, path in zip(image_arrays, paths, strict=True):
+ Image.fromarray(np.asarray(image_array), mode="RGB").save(path)
+
+ return paths
+
+
+def imageArrays(images: torch.Tensor) -> np.ndarray:
+ """Convert an NCHW image tensor in [0, 1] to NHWC uint8 arrays."""
+
if images.ndim != 4:
raise ValueError(f"Expected NCHW image tensor, got {images.shape}")
if images.shape[1] != 3:
raise ValueError(f"Expected three image channels, got {images.shape}")
- paths = outputPaths(output, images.shape[0])
- array = (
+ return (
images.detach()
.float()
.clamp(0, 1)
@@ -41,7 +53,21 @@ def saveImages(images: torch.Tensor, output: Path) -> list[Path]:
.numpy()
)
- for image_array, path in zip(array, paths, strict=True):
- Image.fromarray(np.asarray(image_array), mode="RGB").save(path)
- return paths
+def encodeImages(
+ images: torch.Tensor,
+ image_format: str = "png",
+) -> list[bytes]:
+ """Encode an NCHW image tensor in [0, 1] to image bytes."""
+
+ image_arrays = imageArrays(images)
+ encoded = []
+ pil_format = image_format.upper()
+ for image_array in image_arrays:
+ output = BytesIO()
+ Image.fromarray(np.asarray(image_array), mode="RGB").save(
+ output,
+ format=pil_format,
+ )
+ encoded.append(output.getvalue())
+ return encoded
diff --git a/diffusion_cli/paths.py b/diffusion_cli/paths.py
index 741a178..271da8a 100644
--- a/diffusion_cli/paths.py
+++ b/diffusion_cli/paths.py
@@ -11,6 +11,7 @@ from diffusion_cli.config import (
DIFFUSION_ROLE,
TEXT_ENCODER_ROLE,
VAE_ROLE,
+ ModelPathConfig,
ModelFiles,
ModelSource,
ModelSources,
@@ -104,6 +105,38 @@ def resolveModelSources(args, user_config: UserConfig) -> ModelSources:
)
+def resolveModelSourcesFromConfig(models: ModelPathConfig) -> ModelSources:
+ """Resolve model sources using only user configuration values."""
+
+ checkpoint = models.checkpoint
+ return ModelSources(
+ diffusion_model=resolveComponentSource(
+ None,
+ models.diffusion_model,
+ checkpoint,
+ DIFFUSION_ROLE,
+ CHECKPOINT_DIFFUSION_PREFIX,
+ "models.diffusion_model",
+ ),
+ text_encoder=resolveComponentSource(
+ None,
+ models.text_encoder,
+ checkpoint,
+ TEXT_ENCODER_ROLE,
+ CHECKPOINT_TEXT_ENCODER_PREFIX,
+ "models.text_encoder",
+ ),
+ vae=resolveComponentSource(
+ None,
+ models.vae,
+ checkpoint,
+ VAE_ROLE,
+ CHECKPOINT_VAE_PREFIX,
+ "models.vae",
+ ),
+ )
+
+
def resolveModelFiles(args, user_config: UserConfig) -> ModelFiles:
"""Resolve standalone model paths for compatibility with old callers."""
diff --git a/diffusion_cli/server.py b/diffusion_cli/server.py
new file mode 100644
index 0000000..507d054
--- /dev/null
+++ b/diffusion_cli/server.py
@@ -0,0 +1,108 @@
+"""Flask server construction for API profile mode."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+
+from flask import Flask, request
+
+from diffusion_cli.api_profiles import API_PROFILES
+from diffusion_cli.errors import DiffusionCliError
+from diffusion_cli.generation_service import (
+ MODEL_RESIDENCY_VALUES,
+ GenerationService,
+)
+
+BINARY_JSON_FIELDS = ("init_images", "mask", "extra_images")
+
+
+@dataclass(frozen=True)
+class ServerConfig:
+ """Validated settings for HTTP server mode."""
+
+ api_profile: str
+ host: str
+ port: int
+ model_residency: str
+
+
+def validateServerConfig(
+ api_profile: str | None,
+ host: str,
+ port: int,
+ model_residency: str,
+) -> ServerConfig:
+ """Validate server mode command line settings."""
+
+ if api_profile is None:
+ raise DiffusionCliError("--api-profile is required")
+ if api_profile not in API_PROFILES:
+ raise DiffusionCliError(f"Unknown API profile: {api_profile}")
+ if not host:
+ raise DiffusionCliError("--host must not be empty")
+ if port < 1 or port > 65535:
+ raise DiffusionCliError(f"--port must be from 1 through 65535: {port}")
+ if model_residency not in MODEL_RESIDENCY_VALUES:
+ raise DiffusionCliError(
+ "--model-residency must be one of staged, cpu-cache"
+ )
+ return ServerConfig(
+ api_profile=api_profile,
+ host=host,
+ port=port,
+ model_residency=model_residency,
+ )
+
+
+def _summarizeJsonBody(value):
+ if isinstance(value, dict):
+ result = {}
+ for key, item in value.items():
+ if key in BINARY_JSON_FIELDS and isinstance(item, str):
+ result[key] = f"<{len(item)} bytes>"
+ elif key in BINARY_JSON_FIELDS and isinstance(item, list):
+ result[key] = f"<{len(item)} items>"
+ else:
+ result[key] = _summarizeJsonBody(item)
+ return result
+ if isinstance(value, list):
+ return [_summarizeJsonBody(item) for item in value]
+ return value
+
+
+def createApp(server_config: ServerConfig, generation_service) -> Flask:
+ """Create a Flask app for the selected API profile."""
+
+ app = Flask(__name__)
+ context = SimpleNamespace(
+ server_config=server_config,
+ generation_service=generation_service,
+ )
+
+ @app.before_request
+ def logRequest() -> None:
+ body = None
+ if request.is_json:
+ body = _summarizeJsonBody(request.get_json(silent=True))
+ app.logger.debug(
+ "request method=%s path=%s query=%s json=%r",
+ request.method,
+ request.path,
+ request.query_string.decode("utf-8"),
+ body,
+ )
+
+ API_PROFILES[server_config.api_profile].register_routes(app, context)
+ return app
+
+
+def serve(server_config: ServerConfig, user_config) -> None:
+ """Run the configured Flask development server."""
+
+ generation_service = GenerationService(
+ user_config,
+ model_residency=server_config.model_residency,
+ )
+ app = createApp(server_config, generation_service)
+ app.run(host=server_config.host, port=server_config.port)
diff --git a/diffusion_cli/text_encoder.py b/diffusion_cli/text_encoder.py
index 06cdd70..52d38be 100644
--- a/diffusion_cli/text_encoder.py
+++ b/diffusion_cli/text_encoder.py
@@ -167,6 +167,23 @@ class ZImageTextEncoder:
negative=self.encode(negative_prompt),
)
+ def toDevice(self, device, dtype) -> None:
+ """Move the encoder model to the active generation device."""
+
+ self.device = device
+ self.dtype = dtype
+ self.model.to(device=device, dtype=dtype)
+ self.model.eval()
+
+ def toCpu(self) -> None:
+ """Move the encoder model back to CPU memory."""
+
+ import torch
+
+ self.device = torch.device("cpu")
+ self.model.to(device=self.device)
+ self.model.eval()
+
def _loadTokenizer(self, tokenizer_path: Path):
from transformers import Qwen2Tokenizer
diff --git a/diffusion_cli/vae.py b/diffusion_cli/vae.py
index 5a67d1b..a226c8e 100644
--- a/diffusion_cli/vae.py
+++ b/diffusion_cli/vae.py
@@ -395,3 +395,18 @@ class ZImageVae:
if not torch.all(torch.isfinite(image)):
raise DiffusionCliError("VAE decode produced NaN or Inf values")
return postprocessVaeOutput(image)
+
+ def toDevice(self, device, dtype) -> None:
+ """Move the VAE decoder to the active generation device."""
+
+ self.device = device
+ self.dtype = dtype
+ self.model.to(device=device, dtype=dtype)
+ self.model.eval()
+
+ def toCpu(self) -> None:
+ """Move the VAE decoder back to CPU memory."""
+
+ self.device = torch.device("cpu")
+ self.model.to(device=self.device)
+ self.model.eval()
diff --git a/diffusion_cli/zimage_model.py b/diffusion_cli/zimage_model.py
index 2ff61e6..84ff865 100644
--- a/diffusion_cli/zimage_model.py
+++ b/diffusion_cli/zimage_model.py
@@ -817,6 +817,21 @@ class ZImageModel:
raise DiffusionCliError("Z-Image forward produced NaN or Inf")
return output
+ def toDevice(self, device, dtype) -> None:
+ """Move the diffusion model to the active generation device."""
+
+ self.device = device
+ self.dtype = dtype
+ self.model.to(device=device, dtype=dtype)
+ self.model.eval()
+
+ def toCpu(self) -> None:
+ """Move the diffusion model back to CPU memory."""
+
+ self.device = torch.device("cpu")
+ self.model.to(device=self.device)
+ self.model.eval()
+
def _loadModel(self, model_path: ModelSource | Path | None) -> nn.Module:
if model_path is None:
raise ValueError("model_path is required when model is absent")
diff --git a/pyproject.toml b/pyproject.toml
index 3c64186..34bc199 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,6 +5,7 @@ description = "Standalone CLI experiments for local diffusion model inference."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
+ "flask",
"numpy",
"pillow",
"safetensors",
diff --git a/tests/test_api_profiles.py b/tests/test_api_profiles.py
new file mode 100644
index 0000000..cceb14f
--- /dev/null
+++ b/tests/test_api_profiles.py
@@ -0,0 +1,149 @@
+import base64
+import json
+import unittest
+
+from diffusion_cli.api_profiles import API_PROFILES
+from diffusion_cli.generation_service import GeneratedImage
+from diffusion_cli.server import ServerConfig, createApp
+
+
+class FakeGenerationService:
+ def __init__(self):
+ self.requests = []
+
+ def generateImages(self, request):
+ self.requests.append(request)
+ return [GeneratedImage(b"\x89PNG\r\n\x1a\nimage", "png", 123)]
+
+
+class ApiProfilesTest(unittest.TestCase):
+ def makeClient(self):
+ service = FakeGenerationService()
+ app = createApp(
+ ServerConfig("sillytavern-sdcpp", "127.0.0.1", 7860, "staged"),
+ service,
+ )
+ return app.test_client(), service
+
+ def testRegistryListsSillyTavernProfile(self):
+ self.assertIn("sillytavern-sdcpp", API_PROFILES)
+
+ def testModelsEndpointReturnsOpenAiModelList(self):
+ client, _service = self.makeClient()
+
+ response = client.get("/v1/models")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json["data"][0]["id"], "z-image-local")
+ self.assertEqual(response.json["data"][0]["object"], "model")
+
+ def testImageGenerationsOptionsReturnsNoContent(self):
+ client, _service = self.makeClient()
+
+ response = client.open("/v1/images/generations", method="OPTIONS")
+
+ self.assertEqual(response.status_code, 204)
+ self.assertEqual(response.headers["Allow"], "OPTIONS, POST")
+
+ def testTxt2ImgMinimalRequestReturnsBareBase64Image(self):
+ client, service = self.makeClient()
+
+ response = client.post("/sdapi/v1/txt2img", json={"prompt": "a mug"})
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(service.requests[0].prompt, "a mug")
+ self.assertEqual(response.json["parameters"], {"prompt": "a mug"})
+ image_text = response.json["images"][0]
+ self.assertNotIn("data:image", image_text)
+ self.assertEqual(
+ base64.b64decode(image_text),
+ b"\x89PNG\r\n\x1a\nimage",
+ )
+ self.assertIsInstance(response.json["info"], str)
+ self.assertEqual(json.loads(response.json["info"])["seed"], 123)
+
+ def testTxt2ImgMapsFullSillyTavernPayload(self):
+ client, service = self.makeClient()
+
+ response = client.post(
+ "/sdapi/v1/txt2img",
+ json={
+ "prompt": "a mug",
+ "negative_prompt": "bad",
+ "width": 512,
+ "height": 768,
+ "steps": 12,
+ "cfg_scale": 1.5,
+ "seed": 42,
+ "batch_size": 2,
+ "sampler_name": "euler",
+ "scheduler": "normal",
+ "clip_skip": 1,
+ "model": "ignored",
+ },
+ )
+
+ self.assertEqual(response.status_code, 200)
+ request = service.requests[0]
+ self.assertEqual(request.negative_prompt, "bad")
+ self.assertEqual(request.width, 512)
+ self.assertEqual(request.height, 768)
+ self.assertEqual(request.steps, 12)
+ self.assertEqual(request.cfg, 1.5)
+ self.assertEqual(request.seed, 42)
+ self.assertEqual(request.batch_size, 2)
+
+ def testTxt2ImgSeedMinusOneRequestsRandomSeed(self):
+ client, service = self.makeClient()
+
+ response = client.post(
+ "/sdapi/v1/txt2img",
+ json={"prompt": "a mug", "seed": -1},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIsNone(service.requests[0].seed)
+
+ def testTxt2ImgEmptyPromptReturnsBadRequest(self):
+ client, _service = self.makeClient()
+
+ response = client.post("/sdapi/v1/txt2img", json={"prompt": ""})
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json["error"], "bad_request")
+
+ def testTxt2ImgUnsupportedInitImagesReturnsBadRequest(self):
+ client, _service = self.makeClient()
+
+ response = client.post(
+ "/sdapi/v1/txt2img",
+ json={"prompt": "a mug", "init_images": ["abc"]},
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("init_images", response.json["message"])
+
+ def testTxt2ImgInvalidWidthReturnsBadRequest(self):
+ client, _service = self.makeClient()
+
+ response = client.post(
+ "/sdapi/v1/txt2img",
+ json={"prompt": "a mug", "width": 513},
+ )
+
+ self.assertEqual(response.status_code, 400)
+
+ def testSillyTavernEndpointSequence(self):
+ client, _service = self.makeClient()
+
+ options = client.open("/v1/images/generations", method="OPTIONS")
+ models = client.get("/v1/models")
+ txt2img = client.post("/sdapi/v1/txt2img", json={"prompt": "a mug"})
+
+ self.assertEqual(options.status_code, 204)
+ self.assertEqual(models.status_code, 200)
+ self.assertEqual(txt2img.status_code, 200)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a97848b..7c2a22d 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -53,6 +53,22 @@ class CliTest(unittest.TestCase):
with patch("sys.stderr"):
self.assertEqual(main([]), 2)
+ def testServeParserRequiresApiProfile(self):
+ with patch("sys.stderr"):
+ with self.assertRaises(SystemExit):
+ buildParser().parse_args(["serve"])
+
+ def testServeParserDefaultsToLocalCpuCacheServer(self):
+ args = buildParser().parse_args(
+ ["serve", "--api-profile", "sillytavern-sdcpp"]
+ )
+
+ self.assertEqual(args.command, "serve")
+ self.assertEqual(args.api_profile, "sillytavern-sdcpp")
+ self.assertEqual(args.host, "127.0.0.1")
+ self.assertEqual(args.port, 7860)
+ self.assertEqual(args.model_residency, "cpu-cache")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_generation_service.py b/tests/test_generation_service.py
new file mode 100644
index 0000000..9d3a414
--- /dev/null
+++ b/tests/test_generation_service.py
@@ -0,0 +1,130 @@
+from types import SimpleNamespace
+import unittest
+from unittest.mock import patch
+
+from diffusion_cli.config import ImageGenerationRequest, UserConfig
+from diffusion_cli.generation_service import GenerationService
+
+
+class FakeComponent:
+ load_count = 0
+ to_device_count = 0
+ to_cpu_count = 0
+
+ def __init__(self, *_args, **_kwargs):
+ type(self).load_count += 1
+
+ def toDevice(self, *_args):
+ type(self).to_device_count += 1
+
+ def toCpu(self):
+ type(self).to_cpu_count += 1
+
+
+class FakeTextEncoder(FakeComponent):
+ def encodePrompts(self, *_args):
+ return "conditioning"
+
+
+class FakeModel(FakeComponent):
+ pass
+
+
+class FakeVae(FakeComponent):
+ def decode(self, latent):
+ return latent
+
+
+class GenerationServiceTest(unittest.TestCase):
+ def setUp(self):
+ for component in (FakeTextEncoder, FakeModel, FakeVae):
+ component.load_count = 0
+ component.to_device_count = 0
+ component.to_cpu_count = 0
+
+ def _runTwoRequests(self, model_residency):
+ config = SimpleNamespace(
+ prompt="a mug",
+ negative_prompt="bad",
+ seed=123,
+ batch_size=1,
+ height=64,
+ width=64,
+ steps=1,
+ cfg=1.0,
+ device="cuda",
+ dtype="dtype",
+ dtype_name="fp32",
+ tokenizer_path="tokenizer",
+ )
+ sources = SimpleNamespace(
+ text_encoder="text",
+ diffusion_model="diffusion",
+ vae="vae",
+ )
+ request = ImageGenerationRequest(prompt="a mug")
+ service = GenerationService(
+ UserConfig(models=None, generation=None),
+ model_residency=model_residency,
+ )
+
+ with (
+ patch(
+ "diffusion_cli.generation_service."
+ "buildGenerationConfigFromRequest",
+ return_value=config,
+ ),
+ patch(
+ "diffusion_cli.generation_service."
+ "resolveModelSourcesFromConfig",
+ return_value=sources,
+ ),
+ patch(
+ "diffusion_cli.generation_service.componentDtype",
+ return_value="dtype",
+ ),
+ patch(
+ "diffusion_cli.generation_service.ZImageTextEncoder",
+ FakeTextEncoder,
+ ),
+ patch("diffusion_cli.generation_service.ZImageModel", FakeModel),
+ patch("diffusion_cli.generation_service.ZImageVae", FakeVae),
+ patch(
+ "diffusion_cli.generation_service.sampleLatents",
+ return_value="images",
+ ),
+ patch(
+ "diffusion_cli.generation_service.encodeImages",
+ return_value=[b"png"],
+ ),
+ ):
+ service.generateImages(request)
+ service.generateImages(request)
+
+ def testCpuCacheLoadsComponentsOnceAcrossRequests(self):
+ self._runTwoRequests("cpu-cache")
+
+ self.assertEqual(FakeTextEncoder.load_count, 1)
+ self.assertEqual(FakeModel.load_count, 1)
+ self.assertEqual(FakeVae.load_count, 1)
+
+ def testCpuCacheMovesComponentsForEachRequest(self):
+ self._runTwoRequests("cpu-cache")
+
+ self.assertEqual(FakeTextEncoder.to_device_count, 2)
+ self.assertEqual(FakeModel.to_device_count, 2)
+ self.assertEqual(FakeVae.to_device_count, 2)
+ self.assertEqual(FakeTextEncoder.to_cpu_count, 2)
+ self.assertEqual(FakeModel.to_cpu_count, 2)
+ self.assertEqual(FakeVae.to_cpu_count, 2)
+
+ def testStagedReloadsComponentsForEachRequest(self):
+ self._runTwoRequests("staged")
+
+ self.assertEqual(FakeTextEncoder.load_count, 2)
+ self.assertEqual(FakeModel.load_count, 2)
+ self.assertEqual(FakeVae.load_count, 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_image_io.py b/tests/test_image_io.py
index 5e1c529..d5a3bab 100644
--- a/tests/test_image_io.py
+++ b/tests/test_image_io.py
@@ -5,7 +5,7 @@ from pathlib import Path
from PIL import Image
import torch
-from diffusion_cli.image_io import outputPaths, saveImages
+from diffusion_cli.image_io import encodeImages, outputPaths, saveImages
class ImageIoTest(unittest.TestCase):
@@ -29,6 +29,14 @@ class ImageIoTest(unittest.TestCase):
self.assertEqual(size, (5, 4))
self.assertEqual(mode, "RGB")
+ def testEncodeImagesReturnsPngBytes(self):
+ images = torch.zeros(1, 3, 4, 5)
+
+ encoded = encodeImages(images)
+
+ self.assertEqual(len(encoded), 1)
+ self.assertTrue(encoded[0].startswith(b"\x89PNG\r\n\x1a\n"))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..fea47ab
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,54 @@
+import unittest
+
+from diffusion_cli.errors import DiffusionCliError
+from diffusion_cli.server import ServerConfig, createApp, validateServerConfig
+
+
+class FakeGenerationService:
+ pass
+
+
+class ServerTest(unittest.TestCase):
+ def testValidateServerConfigAcceptsKnownProfile(self):
+ config = validateServerConfig(
+ "sillytavern-sdcpp",
+ "127.0.0.1",
+ 7860,
+ "cpu-cache",
+ )
+
+ self.assertEqual(config.api_profile, "sillytavern-sdcpp")
+ self.assertEqual(config.model_residency, "cpu-cache")
+
+ def testValidateServerConfigRejectsUnknownProfile(self):
+ with self.assertRaises(DiffusionCliError) as context:
+ validateServerConfig("native-sdcpp", "127.0.0.1", 7860, "staged")
+
+ self.assertIn("Unknown API profile", str(context.exception))
+
+ def testValidateServerConfigRejectsInvalidPort(self):
+ with self.assertRaises(DiffusionCliError) as context:
+ validateServerConfig("sillytavern-sdcpp", "127.0.0.1", 0, "staged")
+
+ self.assertIn("--port must be", str(context.exception))
+
+ def testValidateServerConfigRejectsInvalidModelResidency(self):
+ with self.assertRaises(DiffusionCliError) as context:
+ validateServerConfig("sillytavern-sdcpp", "127.0.0.1", 7860, "vram")
+
+ self.assertIn("--model-residency", str(context.exception))
+
+ def testCreateAppRegistersHealthForSelectedProfile(self):
+ app = createApp(
+ ServerConfig("sillytavern-sdcpp", "127.0.0.1", 7860, "staged"),
+ FakeGenerationService(),
+ )
+
+ response = app.test_client().get("/health")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json["api_profile"], "sillytavern-sdcpp")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/uv.lock b/uv.lock
index 2df498c..9db8b4e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -28,6 +28,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.6.17"
@@ -131,6 +140,7 @@ name = "diffusion-cli"
version = "0.1.0"
source = { editable = "." }
dependencies = [
+ { name = "flask" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pillow" },
@@ -144,6 +154,7 @@ dependencies = [
[package.metadata]
requires-dist = [
+ { name = "flask" },
{ name = "numpy" },
{ name = "pillow" },
{ name = "safetensors" },
@@ -162,6 +173,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl", hash = "sha256:8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693", size = 45073, upload-time = "2026-07-03T03:50:30.445Z" },
]
+[[package]]
+name = "flask"
+version = "3.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "blinker" },
+ { name = "click" },
+ { name = "itsdangerous" },
+ { name = "jinja2" },
+ { name = "markupsafe" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
+]
+
[[package]]
name = "fsspec"
version = "2026.6.0"
@@ -269,6 +297,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -1282,3 +1319,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
+
+[[package]]
+name = "werkzeug"
+version = "3.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
+]