- provider_base.py: ProviderBase (name/display_name/get_setup_schema) and CatalogProviderBase (default_model/list_models/is_available) replace the identical default-method bodies duplicated across 7 provider ABCs - provider_media.py: one save_b64/save_bytes/save_url/cache_dir implementation behind image_gen_provider and video_gen_provider - memory_manager.py: _each_provider fan-out helper replaces per-hook try/except loops; _signature_params/_has_var_kwargs unify signature probes - image_routing.py: _resolve_inference_value shared by base_url/api_key resolution; _dict_or_empty/_clean_str/_custom_provider_entries helpers - MemoryProvider/ContextEngine/TTS/browser/web/terminal-env ABC docstrings compacted to their invariants; method names and signatures unchanged
113 lines
3.6 KiB
Python
113 lines
3.6 KiB
Python
"""Shared ``$HERMES_HOME/cache/<kind>/`` materialisation helpers for the
|
|
image/video generation provider ABCs.
|
|
|
|
Several backends (xAI, OpenAI, DeepInfra, FAL) return *ephemeral* delivery URLs
|
|
that expire before a downstream consumer (Telegram ``send_photo``, browser
|
|
fetch) can resolve them, so providers materialise the bytes locally at
|
|
tool-completion time. Filenames are ``<prefix>_<YYYYMMDD_HHMMSS>_<uuid8>.<ext>``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import datetime
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Dict, Tuple
|
|
|
|
|
|
def cache_dir(kind: str) -> Path:
|
|
"""Return ``$HERMES_HOME/cache/<kind>/``, creating parents as needed."""
|
|
from hermes_constants import get_hermes_home
|
|
|
|
path = get_hermes_home() / "cache" / kind
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def cache_path(kind: str, prefix: str, extension: str) -> Path:
|
|
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
short = uuid.uuid4().hex[:8]
|
|
return cache_dir(kind) / f"{prefix}_{ts}_{short}.{extension}"
|
|
|
|
|
|
def save_bytes(kind: str, raw: bytes, *, prefix: str, extension: str) -> Path:
|
|
"""Write raw bytes to the cache and return the absolute path."""
|
|
path = cache_path(kind, prefix, extension)
|
|
path.write_bytes(raw)
|
|
return path
|
|
|
|
|
|
def save_b64(kind: str, b64_data: str, *, prefix: str, extension: str) -> Path:
|
|
"""Decode base64 data into the cache and return the absolute path."""
|
|
return save_bytes(kind, base64.b64decode(b64_data), prefix=prefix, extension=extension)
|
|
|
|
|
|
def save_url(
|
|
kind: str,
|
|
url: str,
|
|
*,
|
|
prefix: str,
|
|
timeout: float,
|
|
max_bytes: int,
|
|
chunk_size: int,
|
|
content_types: Dict[str, str],
|
|
url_extensions: Tuple[str, ...],
|
|
default_extension: str,
|
|
label: str,
|
|
empty_error: str,
|
|
) -> Path:
|
|
"""Stream-download *url* into the cache with a size cap.
|
|
|
|
The extension comes from the response ``Content-Type`` (a small explicit
|
|
table — never inherit a type that points at HTML/JSON from a degenerate
|
|
response), then the URL suffix (some CDNs return
|
|
``application/octet-stream``), then *default_extension*. Raises on any
|
|
network / HTTP / oversize / empty error so callers can fall back to the bare
|
|
URL; a partial file is never left behind.
|
|
"""
|
|
import requests
|
|
|
|
response = requests.get(url, timeout=timeout, stream=True)
|
|
response.raise_for_status()
|
|
|
|
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
|
extension = content_types.get(content_type)
|
|
if extension is None:
|
|
url_path = url.split("?", 1)[0].lower()
|
|
for ext in url_extensions:
|
|
if url_path.endswith(f".{ext}"):
|
|
extension = "jpg" if ext == "jpeg" else ext
|
|
break
|
|
if extension is None:
|
|
extension = default_extension
|
|
|
|
path = cache_path(kind, prefix, extension)
|
|
|
|
bytes_written = 0
|
|
with path.open("wb") as fh:
|
|
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
if not chunk:
|
|
continue
|
|
bytes_written += len(chunk)
|
|
if bytes_written > max_bytes:
|
|
fh.close()
|
|
_unlink_quiet(path)
|
|
raise ValueError(
|
|
f"{label} at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache."
|
|
)
|
|
fh.write(chunk)
|
|
|
|
if bytes_written == 0:
|
|
_unlink_quiet(path)
|
|
raise ValueError(empty_error.format(url=url))
|
|
|
|
return path
|
|
|
|
|
|
def _unlink_quiet(path: Path) -> None:
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|