Files
hermes-agent/plugins/image_gen/_common.py
teknium1 fc08fdb171 feat(image_gen): OpenAI image provider sends custom model ids verbatim and can reuse a named custom endpoint
Custom model ids (#97928): a value of image_gen.openai.model / OPENAI_IMAGE_MODEL outside the
gpt-image-2 / gpt-image-2.5 catalog used to fall through silently to the default tier, so a
gateway serving its own image model names always received `model: gpt-image-2` +
`quality: medium`. resolve_static_model(passthrough=True) now returns the unknown id as the
API model with quality=None, and the request omits `quality` for it — OpenAI-compatible
gateways reject enum values they do not know, and a foreign model has no OpenAI quality
tiers. Only the provider-scoped key and env var pass through; the shared top-level
image_gen.model can hold another provider's id (a FAL path) and is never forwarded.

Named custom endpoint (#83080, config-reuse half): image_gen.openai.provider names a
providers:/custom_providers: entry; its base_url and api_key/key_env fill in whatever
image_gen.openai.base_url / key_env leave unset, so a gateway already declared for chat is
not re-declared with a duplicated key. Explicit image_gen.openai values keep precedence; an
unknown name logs a warning and falls back to the OpenAI variables. The lookup goes through
hermes_cli.runtime_provider._get_named_custom_provider, the same resolver the auxiliary
clients use, so aliases and legacy list entries behave identically.

image_gen is an open config root (deliberately absent from DEFAULT_CONFIG), so
`image_gen.openai.provider` already validates as a known key; docs cover both behaviours.

Live probe (fake /v1/images/generations gateway recording the body, real provider on a temp
HERMES_HOME): before — `custom-image-model` sent as model=gpt-image-2 quality=medium; named
provider → is_available False / auth_required. After — model=custom-image-model, no quality;
named provider → request to the entry's URL with `Bearer <its key_env>`; the catalog model
still maps to gpt-image-2 + quality=medium.
2026-09-19 09:51:12 -07:00

309 lines
13 KiB
Python

"""Shared helpers for the bundled ``image_gen`` provider plugins. Providers are loaded by path
(``hermes_plugins.image_gen__<name>``) and resolve this via the repo root on ``sys.path``;
not a plugin itself (the scanner only looks at directories)."""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from agent.image_gen_provider import (
ImageGenProvider, error_response, normalize_reference_images, save_b64_image, save_url_image)
logger = logging.getLogger(__name__)
# OpenAI-style ``size`` per semantic aspect, shared by every OpenAI-compatible backend.
OPENAI_SIZES: Dict[str, str] = {"landscape": "1536x1024", "square": "1024x1024", "portrait": "1024x1536"}
# gpt-image-2 quality tiers as virtual model ids (same API model, different ``quality`` knob).
GPT_IMAGE_2_API_MODEL = "gpt-image-2"
GPT_IMAGE_2_DEFAULT = "gpt-image-2-medium"
GPT_IMAGE_2_TIERS: Dict[str, Dict[str, Any]] = {
"gpt-image-2-low": {
"display": "GPT Image 2 (Low)",
"speed": "~15s",
"strengths": "Fast iteration, lowest cost",
"quality": "low",
},
"gpt-image-2-medium": {
"display": "GPT Image 2 (Medium)",
"speed": "~40s",
"strengths": "Balanced — default",
"quality": "medium",
},
"gpt-image-2-high": {
"display": "GPT Image 2 (High)",
"speed": "~2min",
"strengths": "Highest fidelity, strongest prompt adherence",
"quality": "high",
},
}
PROMPT_REQUIRED = "Prompt is required and must be a non-empty string"
OPENAI_MISSING = "openai Python package not installed (pip install openai)"
ErrorFn = Callable[..., Dict[str, Any]]
def size_for(aspect: str) -> str:
"""OpenAI ``size`` string for a semantic aspect (square when unknown)."""
return OPENAI_SIZES.get(aspect, OPENAI_SIZES["square"])
def load_image_gen_config(sub: Optional[str] = None) -> Dict[str, Any]:
"""Read ``image_gen`` (or ``image_gen.<sub>``) from config.yaml; ``{}`` on any failure."""
label = "image_gen" if sub is None else f"image_gen.{sub}"
try:
from hermes_cli.config import load_config
cfg = load_config()
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
if sub is not None:
section = section.get(sub) if isinstance(section, dict) else None
return section if isinstance(section, dict) else {}
except Exception as exc: # noqa: BLE001 - config is best-effort
logger.debug("Could not load %s config: %s", label, exc)
return {}
def resolve_static_model(
models: Dict[str, Dict[str, Any]], default: str, *, env_var: str, config_key: str,
explicit: Optional[str] = None, include_top_level: bool = True,
config: Optional[Dict[str, Any]] = None, passthrough: bool = False,
) -> Tuple[str, Dict[str, Any]]:
"""``(model_id, meta)`` from a fixed catalog; first *known* id wins (unknown ids fall through):
explicit → ``env_var`` → ``image_gen.<config_key>.model`` → ``image_gen.model`` → ``default``.
``passthrough``: an unknown id from ``env_var`` or the provider-scoped ``image_gen.<config_key>.model``
is sent verbatim as the API model with no ``quality`` (OpenAI-compatible gateways serve their own
image model names and reject unknown enum values, #97928). The shared top-level ``image_gen.model``
never passes through — it may hold another provider's id."""
if isinstance(explicit, str) and explicit.strip() in models:
return explicit.strip(), models[explicit.strip()]
env_override = os.environ.get(env_var)
if env_override and env_override in models:
return env_override, models[env_override]
cfg = load_image_gen_config() if config is None else config
scoped = cfg.get(config_key)
candidates = [scoped.get("model") if isinstance(scoped, dict) else None]
if include_top_level:
candidates.append(cfg.get("model"))
for candidate in candidates:
if isinstance(candidate, str) and candidate in models:
return candidate, models[candidate]
if passthrough:
custom = next((c.strip() for c in (env_override, candidates[0]) if isinstance(c, str) and c.strip()), "")
if custom:
return custom, {"display": custom, "api_model": custom, "quality": None}
return default, models[default]
def collect_source_images(
image_url: Optional[str], reference_image_urls: Optional[List[str]], limit: Optional[int] = None
) -> List[str]:
"""Primary ``image_url`` first, then normalized references, clamped to ``limit``."""
sources: List[str] = []
if isinstance(image_url, str) and image_url.strip():
sources.append(image_url.strip())
sources.extend(normalize_reference_images(reference_image_urls) or [])
return sources[:limit] if limit is not None else sources
def catalog_rows(
models: Dict[str, Dict[str, Any]],
fields: Iterable[str] = ("display", "speed", "strengths", "price"), *,
price: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Picker rows: ``id`` + ``fields`` (missing ``display`` → id, else ``""``); ``price`` overrides."""
rows = []
for model_id, meta in models.items():
row: Dict[str, Any] = {"id": model_id}
for field in fields:
row[field] = meta.get(field, model_id if field == "display" else "")
if price is not None:
row["price"] = price
rows.append(row)
return rows
def api_key_setup_schema(
name: str, badge: str, tag: str, *, key: str, prompt: str, url: str
) -> Dict[str, Any]:
"""``get_setup_schema()`` dict for a provider authenticated by one env var."""
return {
"name": name, "badge": badge, "tag": tag, "env_vars": [{"key": key, "prompt": prompt, "url": url}],
}
class StaticImageGenProvider(ImageGenProvider):
"""Identity + picker surface from class attributes: ``provider_id``/``label``; fixed catalog via
``models`` (+ ``default_model_id``, ``price``, ``catalog_fields``); single-env-var auth via
``setup`` (kwargs for :func:`api_key_setup_schema`). Dynamic providers override methods."""
provider_id: str
label: str
models: Dict[str, Dict[str, Any]] = {}
default_model_id: Optional[str] = None
price: Optional[str] = None
catalog_fields: Tuple[str, ...] = ("display", "speed", "strengths", "price")
setup: Dict[str, Any] = {}
@property
def name(self) -> str:
return self.provider_id
@property
def display_name(self) -> str:
return self.label
def list_models(self) -> List[Dict[str, Any]]:
return catalog_rows(self.models, self.catalog_fields, price=self.price)
def default_model(self) -> Optional[str]:
return self.default_model_id
def get_setup_schema(self) -> Dict[str, Any]:
return api_key_setup_schema(**self.setup)
def error_factory(provider: str, aspect: str, *, model: str = "", prompt: str = "") -> ErrorFn:
"""Return ``fail(error, error_type, **override)`` pre-bound to this call's context."""
def fail(error: str, error_type: str, **override: Any) -> Dict[str, Any]:
kwargs = dict(provider=provider, model=model, prompt=prompt, aspect_ratio=aspect)
kwargs.update(override)
return error_response(error=error, error_type=error_type, **kwargs)
return fail
def prompt_required_error(provider: str, aspect: str) -> Dict[str, Any]:
return error_factory(provider, aspect)(PROMPT_REQUIRED, "invalid_argument")
def openai_importable() -> bool:
return import_openai("", "")[0] is not None
def import_openai(provider: str, aspect: str) -> Tuple[Any, Optional[Dict[str, Any]]]:
"""Return ``(openai_module, None)`` or ``(None, missing_dependency error)``."""
try:
import openai
except ImportError:
return None, error_factory(provider, aspect)(OPENAI_MISSING, "missing_dependency")
return openai, None
def materialize_image(
b64: Optional[str], url: Optional[str], *, prefix: str, label: str, provider: str, model: str,
prompt: str, aspect: str, log: logging.Logger = logger,
on_url_fail: Optional[Callable[[Exception], None]] = None,
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
"""``(image_ref, None)`` or ``(None, error)`` for a ``(b64_json, url)`` pair. Base64 is always
cached (write failure → ``io_error``); a URL is cached best-effort, falling back to the bare URL."""
fail = error_factory(provider, aspect, model=model, prompt=prompt)
if b64:
try:
return str(save_b64_image(b64, prefix=prefix)), None
except Exception as exc: # noqa: BLE001
return None, fail(f"Could not save image to cache: {exc}", "io_error")
if url:
return cache_url_best_effort(url, prefix=prefix, label=label, log=log, on_fail=on_url_fail), None
return None, fail(f"{label} response contained neither b64_json nor URL", "empty_response")
def cache_url_best_effort(
url: str, *, prefix: str, label: str, log: logging.Logger = logger,
on_fail: Optional[Callable[[Exception], None]] = None,
) -> str:
"""Cache ``url`` locally; on failure warn (or call ``on_fail``) and return the bare URL."""
try:
return str(save_url_image(url, prefix=prefix))
except Exception as exc: # noqa: BLE001
if on_fail is not None:
on_fail(exc)
else:
log.warning("%s image URL %s could not be cached (%s); falling back to bare URL.", label, url, exc)
return url
def requests_error_message(response: Any, exc: Exception) -> str:
"""``error.message`` from an HTTP error body, else the first 300 chars of it."""
try:
return response.json().get("error", {}).get("message", response.text[:300])
except Exception: # noqa: BLE001 - non-JSON / non-dict body
return response.text[:300] if response is not None else str(exc)
@dataclass
class HttpFailure:
"""One failed ``post_json`` attempt as ``(error, error_type)``. ``kind`` ∈ http / timeout /
connection / request / invalid_json; ``message`` = HTTP error text or decode error;
``status`` / ``response`` are set for ``http`` only."""
kind: str
error: str
error_type: str
status: int = 0
message: str = ""
response: Any = None
def record_token_usage(usage: Any, *, model: str, provider: str, base_url: Optional[str] = None) -> None:
"""Record a token-billed image call against the ambient session as task ``image_generation``.
Token-metered image models (OpenRouter chat-image and Image API models, OpenAI ``gpt-image``)
bill exactly like a chat completion, so they get a ``session_model_usage`` row through the
same chokepoint as auxiliary calls. ``usage`` is the response's usage block (dict or SDK
object). Per-image backends (FAL, xAI, Krea, ...) return no token usage and never call this;
a body without tokens is a no-op inside ``record_aux_usage``, as is running outside a turn.
"""
if not usage:
return
from types import SimpleNamespace
from agent.aux_accounting import record_aux_usage
record_aux_usage(
SimpleNamespace(model=model, usage=usage), "image_generation", provider=provider, base_url=base_url)
def post_json(
url: str, *, headers: Dict[str, str], payload: Dict[str, Any], timeout: Any, label: str,
error_message: Callable[[Any, Exception], str] = requests_error_message,
catch_request_exception: bool = False,
) -> Tuple[Optional[Any], Optional[HttpFailure]]:
"""POST ``payload`` → ``(json_body, None)`` or ``(None, failure)``. ``timeout`` goes to ``requests``
verbatim (message reports the read component); ``error_message(response, exc)`` extracts the
backend-specific HTTP error text."""
import requests
read_timeout = timeout[1] if isinstance(timeout, tuple) else timeout
try:
response = requests.post(url, headers=headers, json=payload, timeout=timeout)
response.raise_for_status()
except requests.HTTPError as exc:
resp = exc.response
status = resp.status_code if resp is not None else 0
message = error_message(resp, exc)
return None, HttpFailure(
"http", f"{label} image generation failed ({status}): {message}", "api_error",
status=status, message=message, response=resp)
except requests.Timeout:
return None, HttpFailure(
"timeout", f"{label} image generation timed out ({int(read_timeout)}s)", "timeout")
except requests.ConnectionError as exc:
return None, HttpFailure("connection", f"{label} connection error: {exc}", "connection_error")
except requests.RequestException as exc:
if not catch_request_exception:
raise
return None, HttpFailure("request", f"{label} request failed: {exc}", "api_error")
try:
return response.json(), None
except Exception as exc: # noqa: BLE001
return None, HttpFailure(
"invalid_json", f"{label} returned invalid JSON: {exc}", "invalid_response", message=str(exc),
)