"""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:``. # 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="šŸŽØ", )