File size: 11,601 Bytes
674d56a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""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,
) -> 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``."""
    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]
    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 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),
        )