The salvage moved generated images/videos to cache/generated/<kind>/ so the 24h sweep stops deleting the only copy (#126445), but two consumers still only knew the old swept dirs: - Strict media delivery (HERMES_MEDIA_DELIVERY_STRICT=1) allowlists cache roots via _MEDIA_DELIVERY_CACHE_SUBDIRS; without "generated" a day-old generated image was rejected once the recency window lapsed, i.e. the move broke re-sending artifacts on public gateways. - iter_cache_files (Modal/remote resync) walked the new never-swept tree in full, so every sync re-uploaded the whole generation history. Only files within the sweep window (MEDIA_CACHE_MAX_AGE_HOURS) are synced now. GENERATED_SUBDIR and MEDIA_CACHE_MAX_AGE_HOURS live in agent/provider_media as the single source for the image/video writers, the gateway allowlist and cleanup default, and credential_files. Also trims duplicated comments and fixes the pet orchestrate comments (nothing prunes cache/generated/images). Tests use direct function refs instead of importlib strings.
138 lines
5.6 KiB
Python
138 lines
5.6 KiB
Python
"""Image generation provider ABC.
|
|
|
|
Providers register via ``PluginContext.register_image_gen_provider()`` (from
|
|
``<repo>/plugins/image_gen/<name>/`` or ``~/.hermes/plugins/image_gen/<name>/``);
|
|
the one selected by ``image_gen.provider`` services every ``image_generate`` call.
|
|
One tool covers text-to-image and editing: ``image_url`` / ``reference_image_urls``
|
|
route to the provider's edit endpoint, otherwise text-to-image. Mirrors
|
|
``agent/video_gen_provider.py``. Response dicts come from :func:`success_response`
|
|
/ :func:`error_response` (``success, image, model, prompt, aspect_ratio, modality,
|
|
provider`` + ``error, error_type`` on failure).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from agent import provider_media
|
|
from agent.provider_base import CatalogProviderBase
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
VALID_ASPECT_RATIOS: Tuple[str, ...] = ("landscape", "square", "portrait")
|
|
DEFAULT_ASPECT_RATIO = "landscape"
|
|
|
|
|
|
class ImageGenProvider(CatalogProviderBase):
|
|
"""Abstract base class for an image generation backend. Subclasses implement
|
|
:attr:`name` and :meth:`generate`; ``list_models`` entries may add
|
|
``speed`` / ``strengths`` / ``price`` for the picker."""
|
|
|
|
def capabilities(self) -> Dict[str, Any]:
|
|
"""``modalities`` (``"text"`` and/or ``"image"``) and ``max_reference_images``; optionally
|
|
``supports_upscale`` (bool) and ``creative_controls`` (names from the tool's creative-control
|
|
vocabulary: ``creativity``, ``intensity``, ``complexity``, ``movement``). Surfaced in the
|
|
dynamic tool schema so the model knows when ``image_url`` / ``upscale`` / each control is
|
|
honored, and only declared controls are passed to :meth:`generate`; the text-only default
|
|
keeps non-overriding providers backward compatible."""
|
|
return {"modalities": ["text"], "max_reference_images": 0}
|
|
|
|
@abc.abstractmethod
|
|
def generate(
|
|
self,
|
|
prompt: str,
|
|
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
|
*,
|
|
image_url: Optional[str] = None,
|
|
reference_image_urls: Optional[List[str]] = None,
|
|
**kwargs: Any,
|
|
) -> Dict[str, Any]:
|
|
"""Generate an image, or edit ``image_url`` (``reference_image_urls`` are extra
|
|
style/composition refs, clamped to ``max_reference_images``); any source image
|
|
routes to the edit endpoint. Return :func:`success_response` / :func:`error_response`.
|
|
Unknown ``kwargs`` MUST be ignored (forward compat); ``upscale`` (bool) is a
|
|
post-generation high-res pass, reported as ``upscaled: True`` in ``extra``."""
|
|
|
|
|
|
def resolve_aspect_ratio(value: Optional[str]) -> str:
|
|
"""Clamp to :data:`VALID_ASPECT_RATIOS`; invalid values coerce to landscape so
|
|
the tool surface forgives agent mistakes instead of rejecting them."""
|
|
v = value.strip().lower() if isinstance(value, str) else ""
|
|
return v if v in VALID_ASPECT_RATIOS else DEFAULT_ASPECT_RATIO
|
|
|
|
|
|
def normalize_reference_images(value: Any) -> Optional[List[str]]:
|
|
"""Coerce a str or list into a clean list of non-blank strings; ``None`` when
|
|
nothing usable remains so providers treat "no refs" as one sentinel."""
|
|
if isinstance(value, str):
|
|
value = [value]
|
|
if not isinstance(value, (list, tuple)):
|
|
return None
|
|
return [item.strip() for item in value if isinstance(item, str) and item.strip()] or None
|
|
|
|
|
|
_GENERATED_IMAGE_KIND = f"{provider_media.GENERATED_SUBDIR}/images"
|
|
|
|
|
|
def save_b64_image(b64_data: str, *, prefix: str = "image", extension: str = "png") -> Path:
|
|
"""Decode base64 image data into ``$HERMES_HOME/cache/generated/images/``; return the path."""
|
|
return provider_media.save_b64(_GENERATED_IMAGE_KIND, b64_data, prefix=prefix, extension=extension)
|
|
|
|
|
|
_URL_IMAGE_CONTENT_TYPES = {
|
|
"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp", "image/gif": "gif",
|
|
}
|
|
|
|
|
|
def save_url_image(
|
|
url: str, *, prefix: str = "image", timeout: float = 60.0, max_bytes: int = 25 * 1024 * 1024,
|
|
) -> Path:
|
|
"""Download an (often ephemeral) image URL into ``$HERMES_HOME/cache/generated/images/``. Raises on
|
|
network / HTTP / oversize / empty errors so callers can fall back to the bare URL."""
|
|
return provider_media.save_url(
|
|
_GENERATED_IMAGE_KIND, url, prefix=prefix, timeout=timeout, max_bytes=max_bytes,
|
|
chunk_size=64 * 1024, content_types=_URL_IMAGE_CONTENT_TYPES,
|
|
url_extensions=("png", "jpg", "jpeg", "webp", "gif"), default_extension="png",
|
|
label="Image", empty_error="Image at {url} returned 0 bytes; refusing to cache.",
|
|
)
|
|
|
|
|
|
def success_response(
|
|
*,
|
|
image: str,
|
|
model: str,
|
|
prompt: str,
|
|
aspect_ratio: str,
|
|
provider: str,
|
|
modality: str = "text",
|
|
extra: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Uniform success dict; ``extra`` keys are added without overriding standard ones."""
|
|
payload: Dict[str, Any] = {
|
|
"success": True, "image": image, "model": model, "prompt": prompt,
|
|
"aspect_ratio": aspect_ratio, "modality": modality, "provider": provider,
|
|
}
|
|
for k, v in (extra or {}).items():
|
|
payload.setdefault(k, v)
|
|
return payload
|
|
|
|
|
|
def error_response(
|
|
*,
|
|
error: str,
|
|
error_type: str = "provider_error",
|
|
provider: str = "",
|
|
model: str = "",
|
|
prompt: str = "",
|
|
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
|
) -> Dict[str, Any]:
|
|
"""Build a uniform error response dict."""
|
|
return {
|
|
"success": False, "image": None, "error": error, "error_type": error_type,
|
|
"model": model, "prompt": prompt, "aspect_ratio": aspect_ratio, "provider": provider,
|
|
}
|