BareGit

Add SillyTavern API server mode

- Add a Flask serve command with the explicit sillytavern-sdcpp profile.
- Share generation validation through an internal request model and service.
- Support staged and cpu-cache residency with in-memory PNG responses.
- Cover profile routes, server config, image encoding, and residency behavior.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Jul 7 11:59:19 2026 -0700
Commit: c1d96bb349b223fbd07172b199bf3b93f1ea2d6d

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" },
+]