502 lines
18 KiB
Python
Raw Normal View History

"""Hermes image backend that keeps owner OAuth outside family chat pods."""
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
error_response,
normalize_reference_images,
resolve_aspect_ratio,
save_b64_image,
success_response,
)
BROKER_URL = os.environ.get(
"HERMES_IMAGE_BROKER_URL",
"http://hermes-image-broker.hermes.svc.cluster.local:9002",
).rstrip("/")
DEFAULT_MODEL = "atlas-image-auto-high"
ROUTE_MODELS = {
"auto": "atlas-image-auto-high",
"hosted": "gpt-image-2-high",
"local": "flux-2-klein-4b-local",
}
MODELS = {
"atlas-image-auto-high": (
"Auto (GPT Image quality, local fallback)",
"auto-high",
"Hosted quality first; FLUX fallback when hosted generation fails",
),
"gpt-image-2-low": ("GPT Image 2 (Fast)", "low"),
"gpt-image-2-medium": ("GPT Image 2 (Balanced)", "medium"),
"gpt-image-2-high": ("GPT Image 2 (Highest quality)", "high"),
"flux-2-klein-4b-local": (
"Local FLUX 2 Klein 4B",
"local-high",
"Private local generation and editing on the shared RTX 3080",
),
}
MAX_INPUT_BYTES = 25 << 20
IMAGE_GENERATE_PARAMETERS = {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Detailed description of the image or requested edit.",
},
"aspect_ratio": {
"type": "string",
"enum": ["landscape", "square", "portrait"],
"default": DEFAULT_ASPECT_RATIO,
"description": "Requested output aspect ratio.",
},
"image_url": {
"type": "string",
"description": "Optional uploaded image URL or private local path to edit.",
},
"reference_image_urls": {
"type": "array",
"items": {"type": "string"},
"description": "Optional reference images for the generation or edit.",
},
},
"required": ["prompt"],
}
IMAGE_EDIT_PARAMETERS = {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Detailed description of the requested edit.",
},
"aspect_ratio": {
"type": "string",
"enum": ["landscape", "square", "portrait"],
"default": DEFAULT_ASPECT_RATIO,
"description": "Requested output aspect ratio.",
},
},
"required": ["prompt"],
}
LOCAL_IMAGE_SCHEMA = {
"name": "image_generate_local",
"description": (
"Generate or edit an image only with the private local FLUX model on "
"Atlas hardware. Use this tool whenever the user says local, private, "
"FLUX, on my hardware, or otherwise explicitly rejects a hosted image "
"provider. Never substitute the generic image_generate tool for an "
"explicit local request. The backend is already provisioned; never ask "
"for an endpoint or tell the user to install Diffusers or ComfyUI. "
"For an edit follow-up, pass the newest MEDIA: path from the conversation "
"as image_url, even when the user refers to it only as this, it, the "
"image, or its pictured subject."
),
"parameters": IMAGE_GENERATE_PARAMETERS,
}
HOSTED_IMAGE_SCHEMA = {
"name": "image_generate_hosted",
"description": (
"Generate or edit an image only with hosted OpenAI GPT Image at the "
"highest configured quality. Use this tool when the user explicitly "
"asks for OpenAI, GPT Image, or hosted image generation. Do not use it "
"when the user explicitly requests local or private generation. For an "
"edit follow-up, pass the newest MEDIA: path from the conversation as "
"image_url, even when the user refers to it only as this, it, the image, "
"or its pictured subject."
),
"parameters": IMAGE_GENERATE_PARAMETERS,
}
AUTO_EDIT_SCHEMA = {
"name": "image_edit_latest",
"description": (
"Edit the newest generated image in this private conversation using "
"the automatic image route. Use this compact tool for natural "
"follow-ups such as turn this cat into a clown, change it, edit the "
"image, or make the pictured subject different when the user does not "
"name a provider. The server resolves the source image; do not copy a "
"MEDIA path into the tool call. Hosted quality is tried first and local "
"FLUX is the fallback."
),
"parameters": IMAGE_EDIT_PARAMETERS,
}
LOCAL_EDIT_SCHEMA = {
"name": "image_edit_latest_local",
"description": (
"Edit the newest generated image using only private local FLUX. Use "
"when an edit follow-up says local, private, FLUX, or on my hardware. "
"The server resolves the source image; pass only the edit prompt and "
"optional aspect ratio."
),
"parameters": IMAGE_EDIT_PARAMETERS,
}
HOSTED_EDIT_SCHEMA = {
"name": "image_edit_latest_hosted",
"description": (
"Edit the newest generated image using only hosted OpenAI GPT Image at "
"highest quality. Use when an edit follow-up says OpenAI, GPT Image, "
"hosted, or highest hosted quality. The server resolves the source "
"image; pass only the edit prompt and optional aspect ratio."
),
"parameters": IMAGE_EDIT_PARAMETERS,
}
def _broker_key() -> str:
"""Load the internal relay key from its runtime-only Vault file."""
secret_path = Path(
os.environ.get(
"HERMES_IMAGE_BROKER_KEY_FILE",
"/runtime-access/chat-relay-key",
)
)
try:
value = secret_path.read_text(encoding="utf-8").strip()
if value:
return value
except OSError:
pass
return ""
def _request(
path: str, payload: dict[str, Any] | None, timeout: float
) -> dict[str, Any]:
"""Call the internal broker with a bounded authenticated JSON request."""
key = _broker_key()
if not key:
raise RuntimeError("private image broker key is unavailable")
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = Request(
f"{BROKER_URL}{path}",
data=data,
method="GET" if data is None else "POST",
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urlopen(request, timeout=timeout) as response: # noqa: S310 - fixed internal URL
return json.loads(response.read())
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:1000]
raise RuntimeError(f"image broker returned HTTP {exc.code}: {body}") from exc
except URLError as exc:
raise RuntimeError(f"image broker is unavailable: {exc.reason}") from exc
def _local_image_data_url(value: str) -> str:
"""Convert a private uploaded image to a data URL for broker transport."""
candidate = value.strip()
# Hermes presents generated artifacts to the model as ``MEDIA:<path>``.
# Most models pass only the path back to an edit tool, but accepting the
# marked form makes natural follow-up edits provider-independent.
if candidate.upper().startswith("MEDIA:"):
candidate = candidate[6:].strip()
lowered = candidate.lower()
if lowered.startswith(("http://", "https://", "data:image/")):
return candidate
try:
from agent.file_safety import get_read_block_error
blocked = get_read_block_error(candidate)
if blocked:
raise ValueError(blocked)
except ImportError:
pass
path = Path(os.path.expanduser(candidate)).resolve()
raw = path.read_bytes()
if not raw or len(raw) > MAX_INPUT_BYTES:
raise ValueError("reference image must be between 1 byte and 25 MiB")
if raw.startswith(b"\x89PNG\r\n\x1a\n"):
mime = "image/png"
elif raw.startswith(b"\xff\xd8\xff"):
mime = "image/jpeg"
elif raw.startswith((b"GIF87a", b"GIF89a")):
mime = "image/gif"
elif raw.startswith(b"RIFF") and raw[8:12] == b"WEBP":
mime = "image/webp"
else:
raise ValueError("reference image must be PNG, JPEG, GIF, or WebP")
return f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
def _latest_generated_image() -> str:
"""Return the newest generated artifact from this tenant's private cache."""
home = Path(os.environ.get("HERMES_HOME", "/opt/data")).resolve()
cache = (home / "cache" / "images").resolve()
if not cache.is_dir():
raise ValueError("no generated image is available to edit")
candidates: list[Path] = []
for path in cache.iterdir():
try:
resolved = path.resolve(strict=True)
resolved.relative_to(cache)
except (OSError, ValueError):
continue
if (
resolved.is_file()
and resolved.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"}
):
candidates.append(resolved)
if not candidates:
raise ValueError("no generated image is available to edit")
return str(max(candidates, key=lambda item: item.stat().st_mtime_ns))
class AtlasBrokerImageProvider(ImageGenProvider):
"""High-quality GPT Image generation through the owner-only broker."""
@property
def name(self) -> str:
return "atlas-broker"
@property
def display_name(self) -> str:
return "Atlas private image studio"
def is_available(self) -> bool:
try:
result = _request("/health", None, 5.0)
return bool(result.get("success") and result.get("available"))
except Exception:
return False
def list_models(self) -> list[dict[str, Any]]:
models = []
for model, values in MODELS.items():
display, _quality, *strengths = values
local = model == "flux-2-klein-4b-local"
models.append(
{
"id": model,
"display": display,
"speed": "~2–15min" if local else "~30s–3min",
"strengths": strengths[0]
if strengths
else "OpenAI GPT Image 2 quality",
"price": "local compute" if local else "included account capacity",
}
)
return models
def default_model(self) -> str:
return DEFAULT_MODEL
def capabilities(self) -> dict[str, Any]:
return {"modalities": ["text", "image"], "max_reference_images": 16}
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: str | None = None,
reference_image_urls: list[str] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
prompt = (prompt or "").strip()
aspect = resolve_aspect_ratio(aspect_ratio)
if not prompt:
return error_response(
error="Prompt is required",
error_type="invalid_argument",
provider=self.name,
aspect_ratio=aspect,
)
model = str(kwargs.get("model") or DEFAULT_MODEL)
if model not in MODELS:
model = DEFAULT_MODEL
try:
primary = _local_image_data_url(image_url) if image_url else None
references = [
_local_image_data_url(item)
for item in (normalize_reference_images(reference_image_urls) or [])[
:16
]
]
response = _request(
"/v1/images/generations",
{
"prompt": prompt,
"aspect_ratio": aspect,
"model": model,
"image_url": primary,
"reference_image_urls": references,
},
1200.0,
)
if not response.get("success"):
return error_response(
error=str(response.get("error") or "image generation failed"),
error_type=str(response.get("error_type") or "provider_error"),
provider=self.name,
model=model,
prompt=prompt,
aspect_ratio=aspect,
)
image = save_b64_image(str(response["image_b64"]), prefix=f"atlas_{model}")
return success_response(
image=str(image),
model=str(response.get("model") or model),
prompt=prompt,
aspect_ratio=aspect,
provider=self.name,
modality="image" if primary or references else "text",
extra={
"quality": response.get("quality", MODELS[model][1]),
"size": response.get("size"),
"route": response.get("route"),
"requested_model": response.get("requested_model", model),
"hosted_fallback_reason": response.get("hosted_fallback_reason"),
"input_image_count": response.get(
"input_image_count", len(references) + bool(primary)
),
},
)
except Exception as exc:
return error_response(
error=f"Private image studio failed: {exc}",
error_type="broker_error",
provider=self.name,
model=model,
prompt=prompt,
aspect_ratio=aspect,
)
def _handle_image_generate(args: dict[str, Any], route: str) -> str:
"""Dispatch one image request through a fixed route."""
prompt = str(args.get("prompt") or "").strip()
if not prompt:
return json.dumps(
{
"success": False,
"image": None,
"error": "prompt is required for image generation",
"error_type": "invalid_argument",
}
)
model = ROUTE_MODELS.get(route)
if model is None: # pragma: no cover - routes are fixed by the wrappers
raise ValueError(f"unsupported image route: {route}")
provider = AtlasBrokerImageProvider()
result = provider.generate(
prompt,
str(args.get("aspect_ratio") or DEFAULT_ASPECT_RATIO),
model=model,
image_url=args.get("image_url"),
reference_image_urls=args.get("reference_image_urls"),
)
if isinstance(result, dict):
result.setdefault("requested_route", route)
return json.dumps(result, ensure_ascii=False)
def _handle_local_image(args: dict[str, Any], **_kwargs: Any) -> str:
"""Generate an image without allowing hosted provider substitution."""
return _handle_image_generate(args, "local")
def _handle_hosted_image(args: dict[str, Any], **_kwargs: Any) -> str:
"""Generate an image through the highest-quality hosted route."""
return _handle_image_generate(args, "hosted")
def _handle_latest_edit(args: dict[str, Any], route: str) -> str:
"""Edit the newest tenant artifact without model-generated path arguments."""
try:
image_url = _latest_generated_image()
except ValueError as exc:
return json.dumps(
{
"success": False,
"image": None,
"error": str(exc),
"error_type": "missing_reference",
"requested_route": route,
}
)
return _handle_image_generate({**args, "image_url": image_url}, route)
def _handle_auto_edit(args: dict[str, Any], **_kwargs: Any) -> str:
"""Edit the newest image with hosted-first automatic failover."""
return _handle_latest_edit(args, "auto")
def _handle_local_edit(args: dict[str, Any], **_kwargs: Any) -> str:
"""Edit the newest image without hosted provider substitution."""
return _handle_latest_edit(args, "local")
def _handle_hosted_edit(args: dict[str, Any], **_kwargs: Any) -> str:
"""Edit the newest image only with hosted GPT Image."""
return _handle_latest_edit(args, "hosted")
def _image_tool_available() -> bool:
"""Expose the tool when at least one broker route is healthy."""
return AtlasBrokerImageProvider().is_available()
def register(ctx: Any) -> None:
"""Register generation routes and compact latest-image edit tools."""
ctx.register_image_gen_provider(AtlasBrokerImageProvider())
ctx.register_tool(
name="image_generate_local",
toolset="image_gen",
schema=LOCAL_IMAGE_SCHEMA,
handler=_handle_local_image,
check_fn=_image_tool_available,
requires_env=[],
is_async=False,
description=LOCAL_IMAGE_SCHEMA["description"],
emoji="🎨",
)
ctx.register_tool(
name="image_generate_hosted",
toolset="image_gen",
schema=HOSTED_IMAGE_SCHEMA,
handler=_handle_hosted_image,
check_fn=_image_tool_available,
requires_env=[],
is_async=False,
description=HOSTED_IMAGE_SCHEMA["description"],
emoji="🎨",
)
for name, schema, handler in (
("image_edit_latest", AUTO_EDIT_SCHEMA, _handle_auto_edit),
("image_edit_latest_local", LOCAL_EDIT_SCHEMA, _handle_local_edit),
("image_edit_latest_hosted", HOSTED_EDIT_SCHEMA, _handle_hosted_edit),
):
ctx.register_tool(
name=name,
toolset="image_gen",
schema=schema,
handler=handler,
check_fn=_image_tool_available,
requires_env=[],
is_async=False,
description=schema["description"],
emoji="🎨",
)