diff --git a/plugins/image_gen/_common.py b/plugins/image_gen/_common.py index 16b0c0c186..13ad39fb13 100644 --- a/plugins/image_gen/_common.py +++ b/plugins/image_gen/_common.py @@ -13,11 +13,7 @@ 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, + ImageGenProvider, error_response, normalize_reference_images, save_b64_image, save_url_image ) logger = logging.getLogger(__name__) @@ -79,13 +75,8 @@ def load_image_gen_config(sub: Optional[str] = None) -> Dict[str, Any]: 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, + 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]]: """Pick ``(model_id, meta)`` from a fixed catalog. @@ -123,8 +114,7 @@ def collect_source_images( def catalog_rows( models: Dict[str, Dict[str, Any]], - fields: Iterable[str] = ("display", "speed", "strengths", "price"), - *, + fields: Iterable[str] = ("display", "speed", "strengths", "price"), *, price: Optional[str] = None, ) -> List[Dict[str, Any]]: """Picker rows: ``id`` plus ``fields`` from each meta (missing ``display`` → @@ -213,16 +203,8 @@ def import_openai(provider: str, aspect: str) -> Tuple[Any, Optional[Dict[str, A 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, + b64: Optional[str], url: Optional[str], *, prefix: str, label: str, provider: str, model: str, + prompt: str, aspect: str, log: logging.Logger = logger, ) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: """``(image_ref, None)`` or ``(None, error_dict)`` for a ``(b64_json, url)`` pair. @@ -274,12 +256,7 @@ class HttpFailure: def post_json( - url: str, - *, - headers: Dict[str, str], - payload: Dict[str, Any], - timeout: Any, - label: str, + 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]]: diff --git a/plugins/image_gen/deepinfra/__init__.py b/plugins/image_gen/deepinfra/__init__.py index 2d7593629c..cb3b552808 100644 --- a/plugins/image_gen/deepinfra/__init__.py +++ b/plugins/image_gen/deepinfra/__init__.py @@ -19,19 +19,11 @@ from typing import Any, Dict, List, Optional from agent.secret_scope import get_secret from agent.image_gen_provider import ( - DEFAULT_ASPECT_RATIO, - resolve_aspect_ratio, - save_b64_image, - save_url_image, - success_response, + DEFAULT_ASPECT_RATIO, resolve_aspect_ratio, save_b64_image, save_url_image, success_response ) from plugins.image_gen._common import ( - StaticImageGenProvider, - error_factory, - import_openai, - load_image_gen_config, - prompt_required_error, - size_for, + StaticImageGenProvider, error_factory, import_openai, load_image_gen_config, + prompt_required_error, size_for, ) logger = logging.getLogger(__name__) diff --git a/plugins/image_gen/fal/__init__.py b/plugins/image_gen/fal/__init__.py index 99f15dfbab..d85b238e5f 100644 --- a/plugins/image_gen/fal/__init__.py +++ b/plugins/image_gen/fal/__init__.py @@ -68,12 +68,8 @@ class FalImageGenProvider(StaticImageGenProvider): return {"modalities": ["text"], "max_reference_images": 0, "supports_upscale": True} def generate( - self, - prompt: str, - aspect_ratio: str = DEFAULT_ASPECT_RATIO, - *, - image_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, + self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, *, + image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: """Forward to :func:`tools.image_generation_tool.image_generate_tool` and diff --git a/plugins/image_gen/krea/__init__.py b/plugins/image_gen/krea/__init__.py index 007152a8e5..1ea417400d 100644 --- a/plugins/image_gen/krea/__init__.py +++ b/plugins/image_gen/krea/__init__.py @@ -22,13 +22,8 @@ import requests from agent.secret_scope import get_secret from agent.image_gen_provider import DEFAULT_ASPECT_RATIO, resolve_aspect_ratio, save_url_image, success_response from plugins.image_gen._common import ( - StaticImageGenProvider, - collect_source_images, - error_factory, - load_image_gen_config, - post_json, - prompt_required_error, - resolve_static_model, + ErrorFn, StaticImageGenProvider, collect_source_images, error_factory, load_image_gen_config, + post_json, prompt_required_error, resolve_static_model, ) logger = logging.getLogger(__name__) @@ -202,11 +197,7 @@ def _is_terminal(job: Any) -> bool: def _poll_krea_job( - base_url: str, - auth_token: str, - job_id: str, - *, - timeout_seconds: float = _POLL_TIMEOUT_SECONDS, + base_url: str, auth_token: str, job_id: str, *, timeout_seconds: float = _POLL_TIMEOUT_SECONDS, on_error: Optional[Any] = None, ) -> Any: """Poll ``/jobs/{job_id}`` until terminal. @@ -375,6 +366,68 @@ def _build_payload( return payload +def _submit_job( + base_url: str, auth_token: str, model_path: str, payload: Dict[str, Any], + managed: bool, model_id: str, fail: ErrorFn, +) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: + """POST the generation request; ``(job_id, None)`` or ``(None, error)``.""" + submit_body, failure = post_json( + f"{base_url}/generate/image/krea/krea-2/{model_path}", + headers=_headers(auth_token, managed=managed, json_body=True), + payload=payload, timeout=30, label="Krea", error_message=_submit_error_message, + ) + if failure is not None: + if failure.kind == "http": + status, err_msg = failure.status, failure.message + logger.error("Krea submit failed (%d): %s", status, err_msg) + # Managed 4xx: the model may not be enabled/priced on the Nous + # Portal, or the gateway's shared key hit its concurrency cap (429). + if managed and 400 <= status < 500: + hint = ( + "Krea's shared-key concurrency cap was hit — retry shortly." + if status == 429 + else ( + f"Model '{model_id}' may not be enabled/priced on the " + "Nous Portal's Krea gateway. Set KREA_API_KEY to use " + "Krea directly, or pick a different model via " + "`hermes tools` → Image Generation." + ) + ) + return None, fail( + f"Nous Subscription Krea gateway rejected '{model_id}' " + f"(HTTP {status}): {err_msg}. {hint}", + "api_error", + ) + return None, fail(failure.error, "api_error") + if failure.kind == "timeout": + return None, fail("Krea submit timed out (30s)", "timeout") + if failure.kind == "invalid_json": + return None, fail(f"Krea returned invalid JSON on submit: {failure.message}", "invalid_response") + return None, fail(failure.error, failure.error_type) + job_id = submit_body.get("job_id") + if not isinstance(job_id, str) or not job_id: + return None, fail("Krea submit response missing job_id", "invalid_response") + return job_id, None + + +def _terminal_result_url( + job: Dict[str, Any], job_id: str, fail: ErrorFn +) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: + """Result URL of a terminal job; ``(url, None)`` or ``(None, error)``.""" + result = job.get("result") + if job.get("status") == "failed": + err = result.get("error") if isinstance(result, dict) else None + return None, fail(f"Krea job {job_id} failed: {err or 'unknown error'}", "api_error") + if job.get("status") == "cancelled": + return None, fail(f"Krea job {job_id} was cancelled", "cancelled") + if not isinstance(result, dict): + return None, fail("Krea job completed but result was missing", "empty_response") + result_image_url = _extract_result_url(job) + if result_image_url is None: + return None, fail("Krea result contained no image URL", "empty_response") + return result_image_url, None + + class KreaImageGenProvider(StaticImageGenProvider): """Krea ``Krea 2`` foundation image model backend (Medium + Large).""" @@ -401,12 +454,8 @@ class KreaImageGenProvider(StaticImageGenProvider): } def generate( - self, - prompt: str, - aspect_ratio: str = DEFAULT_ASPECT_RATIO, - *, - image_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, + self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, *, + image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: prompt = (prompt or "").strip() @@ -456,42 +505,9 @@ class KreaImageGenProvider(StaticImageGenProvider): ) # 1. Submit job. - submit_body, failure = post_json( - f"{base_url}/generate/image/krea/krea-2/{meta['path']}", - headers=_headers(auth_token, managed=managed is not None, json_body=True), - payload=payload, timeout=30, label="Krea", error_message=_submit_error_message, - ) - if failure is not None: - if failure.kind == "http": - status, err_msg = failure.status, failure.message - logger.error("Krea submit failed (%d): %s", status, err_msg) - # Managed 4xx: the model may not be enabled/priced on the Nous - # Portal, or the gateway's shared key hit its concurrency cap (429). - if managed is not None and 400 <= status < 500: - hint = ( - "Krea's shared-key concurrency cap was hit — retry shortly." - if status == 429 - else ( - f"Model '{model_id}' may not be enabled/priced on the " - "Nous Portal's Krea gateway. Set KREA_API_KEY to use " - "Krea directly, or pick a different model via " - "`hermes tools` → Image Generation." - ) - ) - return fail( - f"Nous Subscription Krea gateway rejected '{model_id}' " - f"(HTTP {status}): {err_msg}. {hint}", - "api_error", - ) - return fail(failure.error, "api_error") - if failure.kind == "timeout": - return fail("Krea submit timed out (30s)", "timeout") - if failure.kind == "invalid_json": - return fail(f"Krea returned invalid JSON on submit: {failure.message}", "invalid_response") - return fail(failure.error, failure.error_type) - job_id = submit_body.get("job_id") - if not isinstance(job_id, str) or not job_id: - return fail("Krea submit response missing job_id", "invalid_response") + job_id, err = _submit_job(base_url, auth_token, meta["path"], payload, managed is not None, model_id, fail) + if err is not None: + return err # 2. Poll. Polling is bound to the same principal at the gateway, so the # managed path polls the gateway's ``/jobs/{id}`` with the Nous token. @@ -509,17 +525,9 @@ class KreaImageGenProvider(StaticImageGenProvider): return fail("Krea returned non-dict job body", "invalid_response") # 3. Terminal — extract result. - result = job.get("result") - if job.get("status") == "failed": - err = result.get("error") if isinstance(result, dict) else None - return fail(f"Krea job {job_id} failed: {err or 'unknown error'}", "api_error") - if job.get("status") == "cancelled": - return fail(f"Krea job {job_id} was cancelled", "cancelled") - if not isinstance(result, dict): - return fail("Krea job completed but result was missing", "empty_response") - result_image_url = _extract_result_url(job) - if result_image_url is None: - return fail("Krea result contained no image URL", "empty_response") + result_image_url, err = _terminal_result_url(job, job_id, fail) + if err is not None: + return err # Krea Enhance pass. Precedence: explicit kwarg > ``image_gen.krea.upscale`` # config > per-model catalog default. Best-effort: failure falls back to diff --git a/plugins/image_gen/meta-ai/__init__.py b/plugins/image_gen/meta-ai/__init__.py index 87e5fe8d76..2cc0be8b5a 100644 --- a/plugins/image_gen/meta-ai/__init__.py +++ b/plugins/image_gen/meta-ai/__init__.py @@ -15,20 +15,11 @@ from typing import Any, Dict, List, Optional, Tuple from agent.secret_scope import get_secret from agent.image_gen_provider import ( - DEFAULT_ASPECT_RATIO, - resolve_aspect_ratio, - save_b64_image, - save_url_image, - success_response, + DEFAULT_ASPECT_RATIO, resolve_aspect_ratio, save_b64_image, save_url_image, success_response ) from plugins.image_gen._common import ( - StaticImageGenProvider, - error_factory, - import_openai, - openai_importable, - prompt_required_error, - resolve_static_model, - size_for, + StaticImageGenProvider, error_factory, import_openai, openai_importable, prompt_required_error, + resolve_static_model, size_for, ) logger = logging.getLogger(__name__) @@ -90,12 +81,8 @@ class MetaImageGenProvider(StaticImageGenProvider): return {"modalities": ["text"], "max_reference_images": 0} def generate( - self, - prompt: str, - aspect_ratio: str = DEFAULT_ASPECT_RATIO, - *, - image_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, + self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, *, + image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: prompt = (prompt or "").strip() diff --git a/plugins/image_gen/openai-codex/__init__.py b/plugins/image_gen/openai-codex/__init__.py index 942e2cfba3..059318cb15 100644 --- a/plugins/image_gen/openai-codex/__init__.py +++ b/plugins/image_gen/openai-codex/__init__.py @@ -22,15 +22,9 @@ from typing import Any, Dict, List, Optional, Tuple from agent.image_gen_provider import DEFAULT_ASPECT_RATIO, resolve_aspect_ratio, save_b64_image, success_response from plugins.image_gen._common import ( - GPT_IMAGE_2_API_MODEL as API_MODEL, - GPT_IMAGE_2_DEFAULT as DEFAULT_MODEL, - GPT_IMAGE_2_TIERS, - StaticImageGenProvider, - collect_source_images, - error_factory, - prompt_required_error, - resolve_static_model, - size_for, + GPT_IMAGE_2_API_MODEL as API_MODEL, GPT_IMAGE_2_DEFAULT as DEFAULT_MODEL, GPT_IMAGE_2_TIERS, + StaticImageGenProvider, collect_source_images, error_factory, prompt_required_error, + resolve_static_model, size_for, ) logger = logging.getLogger(__name__) @@ -364,12 +358,8 @@ class OpenAICodexImageGenProvider(StaticImageGenProvider): return {"modalities": ["text", "image"], "max_reference_images": _MAX_REFERENCE_IMAGES} def generate( - self, - prompt: str, - aspect_ratio: str = DEFAULT_ASPECT_RATIO, - *, - image_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, + self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, *, + image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: prompt = (prompt or "").strip() diff --git a/plugins/image_gen/openai/__init__.py b/plugins/image_gen/openai/__init__.py index 9fa7a994fd..b6a59f973f 100644 --- a/plugins/image_gen/openai/__init__.py +++ b/plugins/image_gen/openai/__init__.py @@ -13,18 +13,9 @@ from typing import Any, Dict, List, Optional, Tuple from agent.secret_scope import get_secret from agent.image_gen_provider import DEFAULT_ASPECT_RATIO, resolve_aspect_ratio, success_response from plugins.image_gen._common import ( - GPT_IMAGE_2_API_MODEL as API_MODEL, - GPT_IMAGE_2_DEFAULT as DEFAULT_MODEL, - GPT_IMAGE_2_TIERS, - StaticImageGenProvider, - collect_source_images, - error_factory, - import_openai, - materialize_image, - openai_importable, - prompt_required_error, - resolve_static_model, - size_for, + GPT_IMAGE_2_API_MODEL as API_MODEL, GPT_IMAGE_2_DEFAULT as DEFAULT_MODEL, GPT_IMAGE_2_TIERS, + StaticImageGenProvider, collect_source_images, error_factory, import_openai, materialize_image, + openai_importable, prompt_required_error, resolve_static_model, size_for, ) logger = logging.getLogger(__name__) @@ -84,12 +75,8 @@ class OpenAIImageGenProvider(StaticImageGenProvider): return {"modalities": ["text", "image"], "max_reference_images": 16} def generate( - self, - prompt: str, - aspect_ratio: str = DEFAULT_ASPECT_RATIO, - *, - image_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, + self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, *, + image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: prompt = (prompt or "").strip() diff --git a/plugins/image_gen/openrouter/__init__.py b/plugins/image_gen/openrouter/__init__.py index 1b06d3c53c..db22b12f1d 100644 --- a/plugins/image_gen/openrouter/__init__.py +++ b/plugins/image_gen/openrouter/__init__.py @@ -20,19 +20,11 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from agent.image_gen_provider import ( - DEFAULT_ASPECT_RATIO, - ImageGenProvider, - error_response, - resolve_aspect_ratio, - save_b64_image, - save_url_image, - success_response, + DEFAULT_ASPECT_RATIO, ImageGenProvider, error_response, resolve_aspect_ratio, save_b64_image, + save_url_image, success_response, ) from plugins.image_gen._common import ( - HttpFailure as HttpFailure, - error_factory, - load_image_gen_config, - post_json, + HttpFailure as HttpFailure, error_factory, load_image_gen_config, post_json ) logger = logging.getLogger(__name__) @@ -574,15 +566,8 @@ class OpenRouterCompatImageProvider(ImageGenProvider): """ def __init__( - self, - *, - provider_name: str, - display_name: str, - runtime_name: str, - config_key: str, - model_env_var: str, - setup_schema: Dict[str, Any], - supports_image_api: bool = False, + self, *, provider_name: str, display_name: str, runtime_name: str, config_key: str, + model_env_var: str, setup_schema: Dict[str, Any], supports_image_api: bool = False, ) -> None: self._name = provider_name self._display = display_name @@ -861,12 +846,8 @@ class OpenRouterCompatImageProvider(ImageGenProvider): ), None def generate( - self, - prompt: str, - aspect_ratio: str = DEFAULT_ASPECT_RATIO, - *, - image_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, + self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, *, + image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: fail = error_factory(self._name, aspect_ratio) diff --git a/plugins/image_gen/xai/__init__.py b/plugins/image_gen/xai/__init__.py index 31af375ef0..d3c695fafa 100644 --- a/plugins/image_gen/xai/__init__.py +++ b/plugins/image_gen/xai/__init__.py @@ -19,21 +19,12 @@ import requests from agent.image_gen_provider import DEFAULT_ASPECT_RATIO, resolve_aspect_ratio, success_response from plugins.image_gen._common import ( - StaticImageGenProvider, - catalog_rows, - collect_source_images, - error_factory, - load_image_gen_config, - materialize_image, - post_json, + StaticImageGenProvider, catalog_rows, collect_source_images, error_factory, + load_image_gen_config, materialize_image, post_json, ) from tools.xai_http import ( - build_xai_storage_options, - hermes_xai_user_agent, - maybe_mark_xai_storage_notice_seen, - read_xai_imagine_storage_config, - resolve_xai_http_credentials, - xai_storage_notice_text, + build_xai_storage_options, hermes_xai_user_agent, maybe_mark_xai_storage_notice_seen, + read_xai_imagine_storage_config, resolve_xai_http_credentials, xai_storage_notice_text, ) logger = logging.getLogger(__name__) @@ -242,12 +233,8 @@ class XAIImageGenProvider(StaticImageGenProvider): } def generate( - self, - prompt: str, - aspect_ratio: str = DEFAULT_ASPECT_RATIO, - *, - image_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, + self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, *, + image_url: Optional[str] = None, reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: """Text-to-image, or image editing via ``/v1/images/edits`` (JSON body —