648 lines
26 KiB
Python
648 lines
26 KiB
Python
"""Krea image generation backend.
|
|
|
|
Exposes Krea's ``Krea 2`` foundation image model family (Medium, Large,
|
|
Medium Turbo) as an :class:`ImageGenProvider`.
|
|
|
|
Krea's API is asynchronous: the generate endpoint returns a ``job_id`` polled
|
|
at ``GET /jobs/{job_id}``. This provider hides that roundtrip behind the
|
|
synchronous ``generate()`` contract: submit, poll every 2s with light backoff,
|
|
materialise the result URL to local cache, return the usual dict.
|
|
|
|
Selection precedence (first hit wins): ``model`` kwarg → ``KREA_IMAGE_MODEL``
|
|
env → ``image_gen.krea.model`` → ``image_gen.model`` (when it's one of our
|
|
IDs) → :data:`DEFAULT_MODEL` (``krea-2-medium``, Krea's "start here" pick).
|
|
|
|
Docs: https://docs.krea.ai/developers/krea-2/overview
|
|
API: https://docs.krea.ai/api-reference/krea/krea-2-large
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import requests
|
|
|
|
from agent.secret_scope import get_secret
|
|
from agent.image_gen_provider import (
|
|
DEFAULT_ASPECT_RATIO,
|
|
ImageGenProvider,
|
|
resolve_aspect_ratio,
|
|
save_url_image,
|
|
success_response,
|
|
)
|
|
from plugins.image_gen._common import (
|
|
api_key_setup_schema,
|
|
catalog_rows,
|
|
collect_source_images,
|
|
error_factory,
|
|
load_image_gen_config,
|
|
prompt_required_error,
|
|
resolve_static_model,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BASE_URL = "https://api.krea.ai"
|
|
|
|
# ``path`` is Krea's URL segment. ``upscale`` (the Enhance pass) is opt-in for
|
|
# every tier: default-on enhance passes degraded output quality, and Large is
|
|
# 2K native anyway.
|
|
_MODELS: Dict[str, Dict[str, Any]] = {
|
|
"krea-2-medium": {
|
|
"display": "Krea 2 Medium",
|
|
"speed": "~15-25s",
|
|
"strengths": "Illustration, anime, painting, expressive styles. Faster + cheaper.",
|
|
"price": "$0.030 (text) / $0.035 (style refs) / $0.040 (moodboards)",
|
|
"path": "medium",
|
|
"upscale": False,
|
|
},
|
|
"krea-2-large": {
|
|
"display": "Krea 2 Large",
|
|
"speed": "~25-60s",
|
|
"strengths": "Photorealism, raw textured looks (motion blur, grain), expressive styles.",
|
|
"price": "$0.060 (text) / $0.065 (style refs) / $0.070 (moodboards)",
|
|
"path": "large",
|
|
"upscale": False,
|
|
},
|
|
"krea-2-medium-turbo": {
|
|
"display": "Krea 2 Medium Turbo",
|
|
"speed": "~8-15s",
|
|
"strengths": "Fastest Krea 2 — medium quality at lower latency / cost.",
|
|
"price": "$0.015 (text) / $0.0175 (style refs)",
|
|
"path": "medium-turbo",
|
|
"upscale": False,
|
|
},
|
|
}
|
|
|
|
DEFAULT_MODEL = "krea-2-medium"
|
|
|
|
# Hermes' 3 abstract ratios → Krea's enum (1:1, 4:3, 3:2, 16:9, 2.35:1, 4:5, 2:3, 9:16).
|
|
_ASPECT_MAP = {"landscape": "16:9", "square": "1:1", "portrait": "9:16"}
|
|
|
|
# Only resolution Krea currently supports.
|
|
DEFAULT_RESOLUTION = "1K"
|
|
|
|
# image_style_references entries are objects ({"url", "strength"}), not bare
|
|
# strings; a URL without explicit strength gets Krea's recommended start (range -2..2).
|
|
_DEFAULT_STYLE_REFERENCE_STRENGTH = 0.6
|
|
_MAX_STYLE_REFERENCES = 10
|
|
|
|
_VALID_CREATIVITY = {"raw", "low", "medium", "high"}
|
|
|
|
# Polling: Krea recommends 2-5s; start at 2s, back off to 5s for long jobs
|
|
# (Large can take ~1min). Ceiling matches Krea's hosted-tool timeout of 3 min.
|
|
_POLL_INITIAL_INTERVAL = 2.0
|
|
_POLL_MAX_INTERVAL = 5.0
|
|
_POLL_BACKOFF = 1.3
|
|
_POLL_TIMEOUT_SECONDS = 180.0
|
|
|
|
# Statuses worth retrying while polling. Everything else (401/402/403/404,
|
|
# other 4xx) is permanent — surface it immediately instead of burning the
|
|
# 180s deadline on a request that will never succeed.
|
|
_RETRYABLE_POLL_STATUSES = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
|
|
|
|
_TERMINAL_STATES = {"completed", "failed", "cancelled"}
|
|
|
|
# Krea Enhance — the optional ``upscale`` pass after generation ("1.5K native,
|
|
# 4K via Enhancer" is Krea's own pipeline shape). Cheap creative enhancer, max 8K.
|
|
_ENHANCE_PATH = "/generate/enhance/krea/enhance"
|
|
_ENHANCE_SCALE_FACTOR = 2
|
|
|
|
_USER_AGENT = "Hermes-Agent/1.0 (krea-image-gen)"
|
|
|
|
|
|
def _load_krea_config() -> Dict[str, Any]:
|
|
"""Read ``image_gen`` (the krea section lives under ``image_gen.krea``)."""
|
|
return load_image_gen_config()
|
|
|
|
|
|
def _krea_section() -> Dict[str, Any]:
|
|
section = _load_krea_config().get("krea")
|
|
return section if isinstance(section, dict) else {}
|
|
|
|
|
|
def _resolve_model(explicit: Optional[str] = None) -> Tuple[str, Dict[str, Any]]:
|
|
"""``(model_id, meta)``: explicit → ``KREA_IMAGE_MODEL`` → ``image_gen.krea.model``
|
|
→ ``image_gen.model`` → :data:`DEFAULT_MODEL`."""
|
|
return resolve_static_model(
|
|
_MODELS, DEFAULT_MODEL, env_var="KREA_IMAGE_MODEL", config_key="krea",
|
|
explicit=explicit, config=_load_krea_config(),
|
|
)
|
|
|
|
|
|
def _resolve_managed_krea_gateway():
|
|
"""Managed Krea gateway config when the user is on the managed path, else ``None``.
|
|
|
|
Strict selection: managed when the stored ``image_gen`` selection is
|
|
``nous`` (or legacy ``use_gateway: true``), or on a never-configured install
|
|
with no direct ``KREA_API_KEY``. An explicit vendor selection (``krea``,
|
|
``fal``, ...) pins the direct path. Never raises — plugin discovery and
|
|
availability scans must stay robust.
|
|
"""
|
|
try:
|
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
|
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_selection
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("Managed Krea gateway resolution unavailable: %s", exc)
|
|
return None
|
|
|
|
try:
|
|
selected = read_selection("image_gen")
|
|
except Exception: # noqa: BLE001
|
|
selected = None
|
|
if selected is not None and selected != NOUS_MANAGED_PROVIDER:
|
|
return None
|
|
if selected is None and get_secret("KREA_API_KEY"):
|
|
return None
|
|
|
|
try:
|
|
return resolve_managed_tool_gateway("krea")
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("Managed Krea gateway resolution failed: %s", exc)
|
|
return None
|
|
|
|
|
|
def _managed_krea_gateway_ready() -> bool:
|
|
"""Cheap, offline-friendly probe for managed Krea availability."""
|
|
try:
|
|
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
|
|
|
return bool(is_managed_tool_gateway_ready("krea"))
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
|
|
|
|
def _resolve_creativity(value: Optional[str]) -> str:
|
|
"""Coerce ``creativity`` kwarg (then config) to a valid Krea value; default ``medium``."""
|
|
for candidate in (value, _krea_section().get("creativity")):
|
|
if isinstance(candidate, str) and candidate.strip().lower() in _VALID_CREATIVITY:
|
|
return candidate.strip().lower()
|
|
return "medium"
|
|
|
|
|
|
def _headers(auth_token: str, *, managed: bool, json_body: bool) -> Dict[str, str]:
|
|
headers = {"Authorization": f"Bearer {auth_token}", "User-Agent": _USER_AGENT}
|
|
if json_body:
|
|
headers["Content-Type"] = "application/json"
|
|
if managed:
|
|
# The gateway derives the per-generation billing idempotency boundary
|
|
# from this header (else a body fingerprint); a fresh key per submit
|
|
# keeps each generation a distinct billable execution.
|
|
headers["x-idempotency-key"] = str(uuid.uuid4())
|
|
return headers
|
|
|
|
|
|
def _submit_error_message(resp: Any, exc: Exception) -> str:
|
|
try:
|
|
body = resp.json() if resp is not None else {}
|
|
return (
|
|
body.get("error", {}).get("message")
|
|
if isinstance(body.get("error"), dict)
|
|
else body.get("message") or body.get("detail")
|
|
) or (resp.text[:300] if resp is not None else str(exc))
|
|
except Exception: # noqa: BLE001
|
|
return resp.text[:300] if resp is not None else str(exc)
|
|
|
|
|
|
def _is_terminal(job: Any) -> bool:
|
|
"""``completed_at`` is a backstop terminal marker even when ``status`` is an
|
|
unfamiliar enum (Krea adds pending states — backlogged/scheduled/sampling — over time)."""
|
|
return isinstance(job, dict) and (
|
|
job.get("status") in _TERMINAL_STATES or bool(job.get("completed_at"))
|
|
)
|
|
|
|
|
|
def _poll_krea_job(
|
|
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.
|
|
|
|
Returns the terminal job dict, or ``None`` when the poll gave up. With
|
|
``on_error(kind, detail)`` supplied (the main generation path), a fatal
|
|
poll failure returns whatever that callback returns instead of ``None``;
|
|
without it (the best-effort Enhance pass) failures only log.
|
|
``kind`` ∈ ``http`` (detail = status) / ``timeout`` / ``invalid_json`` /
|
|
``deadline`` (detail = last status seen).
|
|
"""
|
|
job_url = f"{base_url}/jobs/{job_id}"
|
|
headers = _headers(auth_token, managed=False, json_body=False)
|
|
interval = _POLL_INITIAL_INTERVAL
|
|
deadline = time.monotonic() + timeout_seconds
|
|
last_status: Optional[str] = None
|
|
enhance = on_error is None
|
|
|
|
def give_up(kind: str, detail: Any, warning: str, *warn_args: Any) -> Any:
|
|
if on_error is None:
|
|
logger.warning(warning, *warn_args)
|
|
return None
|
|
return on_error(kind, detail)
|
|
|
|
while True:
|
|
time.sleep(interval)
|
|
interval = min(interval * _POLL_BACKOFF, _POLL_MAX_INTERVAL)
|
|
try:
|
|
resp = requests.get(job_url, headers=headers, timeout=30)
|
|
resp.raise_for_status()
|
|
except requests.HTTPError as exc:
|
|
status = exc.response.status_code if exc.response is not None else 0
|
|
if not enhance:
|
|
logger.error("Krea poll failed (%d) for job %s", status, job_id)
|
|
# Fail fast on permanent statuses; retry transient ones.
|
|
if status not in _RETRYABLE_POLL_STATUSES or time.monotonic() >= deadline:
|
|
return give_up("http", status, "Krea enhance poll failed (%d) for job %s", status, job_id)
|
|
continue
|
|
except (requests.Timeout, requests.ConnectionError) as exc:
|
|
if not enhance:
|
|
logger.warning("Krea poll transient error for job %s: %s", job_id, exc)
|
|
if time.monotonic() >= deadline:
|
|
return give_up("timeout", exc, "Krea enhance poll gave up for job %s: %s", job_id, exc)
|
|
continue
|
|
except Exception as exc: # noqa: BLE001 — enhance-only: any other failure is best-effort
|
|
if not enhance:
|
|
raise
|
|
if time.monotonic() >= deadline:
|
|
logger.warning("Krea enhance poll gave up for job %s: %s", job_id, exc)
|
|
return None
|
|
continue
|
|
|
|
try:
|
|
job = resp.json()
|
|
except Exception as exc: # noqa: BLE001
|
|
if not enhance:
|
|
logger.warning("Krea poll returned invalid JSON for job %s: %s", job_id, exc)
|
|
if time.monotonic() >= deadline:
|
|
return give_up("invalid_json", exc, "Krea enhance poll gave up for job %s: %s", job_id, exc)
|
|
continue
|
|
|
|
if isinstance(job, dict) and isinstance(job.get("status"), str):
|
|
last_status = job["status"]
|
|
if _is_terminal(job):
|
|
return job
|
|
if time.monotonic() >= deadline:
|
|
return give_up(
|
|
"deadline", last_status,
|
|
"Krea enhance job %s did not finish in %ds", job_id, int(timeout_seconds),
|
|
)
|
|
|
|
|
|
def _extract_result_url(job: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
"""First result URL from a terminal Krea job: ``result.urls[]`` per Krea's
|
|
job-lifecycle docs, falling back to a single ``result.url``."""
|
|
result = job.get("result") if isinstance(job, dict) else None
|
|
if not isinstance(result, dict):
|
|
return None
|
|
urls = result.get("urls")
|
|
for candidate in [*(urls if isinstance(urls, list) else []), result.get("url")]:
|
|
if isinstance(candidate, str) and candidate.strip():
|
|
return candidate.strip()
|
|
return None
|
|
|
|
|
|
def _enhance_image(
|
|
base_url: str,
|
|
auth_token: str,
|
|
image_url: str,
|
|
prompt: str,
|
|
*,
|
|
managed: bool,
|
|
) -> Optional[str]:
|
|
"""Run Krea Enhance on ``image_url``; return the enhanced URL or None.
|
|
|
|
Best-effort: any submit/poll/result failure logs and returns ``None`` so
|
|
the caller falls back to the original image — an upscale failure must
|
|
never destroy an already-successful generation.
|
|
"""
|
|
payload: Dict[str, Any] = {
|
|
"image_url": image_url,
|
|
"image_scaling_factor": _ENHANCE_SCALE_FACTOR,
|
|
# The original prompt guides detail; default ai_strength (0.4) adds
|
|
# detail without redrawing the composition.
|
|
"prompt": prompt,
|
|
}
|
|
try:
|
|
resp = requests.post(
|
|
f"{base_url}{_ENHANCE_PATH}",
|
|
headers=_headers(auth_token, managed=managed, json_body=True),
|
|
json=payload, timeout=30,
|
|
)
|
|
resp.raise_for_status()
|
|
job_id = (resp.json() or {}).get("job_id")
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("Krea Enhance submit failed: %s", exc)
|
|
return None
|
|
if not isinstance(job_id, str) or not job_id:
|
|
logger.warning("Krea Enhance submit response missing job_id")
|
|
return None
|
|
|
|
job = _poll_krea_job(base_url, auth_token, job_id)
|
|
if not isinstance(job, dict) or job.get("status") in {"failed", "cancelled"}:
|
|
logger.warning("Krea Enhance job %s did not complete successfully", job_id)
|
|
return None
|
|
return _extract_result_url(job)
|
|
|
|
|
|
def _collect_style_refs(
|
|
image_url: Optional[str], reference_image_urls: Optional[List[str]], legacy_refs: Any
|
|
) -> List[Any]:
|
|
"""Reference images for style transfer: unified ``image_url`` +
|
|
``reference_image_urls`` first, then the legacy ``image_style_references``
|
|
kwarg, whose entries may be URL strings or Krea ref objects (passed through
|
|
verbatim). Strings are deduped in order; capped at Krea's limit of 10."""
|
|
refs: List[Any] = collect_source_images(image_url, reference_image_urls)
|
|
if isinstance(legacy_refs, list):
|
|
for ref in legacy_refs:
|
|
if isinstance(ref, str):
|
|
if ref.strip():
|
|
refs.append(ref.strip())
|
|
elif ref:
|
|
refs.append(ref)
|
|
seen: set = set()
|
|
deduped: List[Any] = []
|
|
for r in refs:
|
|
if isinstance(r, str):
|
|
if r in seen:
|
|
continue
|
|
seen.add(r)
|
|
deduped.append(r)
|
|
return deduped[:_MAX_STYLE_REFERENCES]
|
|
|
|
|
|
class KreaImageGenProvider(ImageGenProvider):
|
|
"""Krea ``Krea 2`` foundation image model backend (Medium + Large)."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "krea"
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
return "Krea"
|
|
|
|
def is_available(self) -> bool:
|
|
# Direct key OR the managed Nous gateway (Nous Subscription), so portal
|
|
# users without a Krea key can still reach Krea 2.
|
|
return bool(get_secret("KREA_API_KEY")) or _managed_krea_gateway_ready()
|
|
|
|
def list_models(self) -> List[Dict[str, Any]]:
|
|
return catalog_rows(_MODELS)
|
|
|
|
def default_model(self) -> Optional[str]:
|
|
return DEFAULT_MODEL
|
|
|
|
def get_setup_schema(self) -> Dict[str, Any]:
|
|
return api_key_setup_schema(
|
|
"Krea", "paid",
|
|
"Krea 2 foundation model — Medium ($0.03), Large ($0.06), Medium Turbo ($0.015). Style transfer, moodboards, reference-guided generation. Direct key or managed Nous Subscription gateway.",
|
|
key="KREA_API_KEY", prompt="Krea API key", url="https://www.krea.ai/settings/api-tokens",
|
|
)
|
|
|
|
def capabilities(self) -> Dict[str, Any]:
|
|
# Reference-guided generation via image_style_references (up to 10) plus
|
|
# the opt-in Enhance upscale pass.
|
|
return {
|
|
"modalities": ["text", "image"],
|
|
"max_reference_images": _MAX_STYLE_REFERENCES,
|
|
"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,
|
|
**kwargs: Any,
|
|
) -> Dict[str, Any]:
|
|
prompt = (prompt or "").strip()
|
|
aspect = resolve_aspect_ratio(aspect_ratio)
|
|
krea_ar = _ASPECT_MAP.get(aspect, "1:1")
|
|
|
|
style_refs = _collect_style_refs(
|
|
image_url, reference_image_urls, kwargs.get("image_style_references")
|
|
)
|
|
modality = "image" if style_refs else "text"
|
|
|
|
if not prompt:
|
|
return prompt_required_error("krea", aspect)
|
|
|
|
# Managed Nous gateway (Nous Subscription) owns the shared Krea
|
|
# credential and meters per generation, so the caller token is the Nous
|
|
# access token; otherwise the direct Krea API with a BYO ``KREA_API_KEY``.
|
|
managed = _resolve_managed_krea_gateway()
|
|
if managed is not None:
|
|
base_url = managed.gateway_origin.rstrip("/")
|
|
auth_token = managed.nous_user_token
|
|
else:
|
|
base_url = BASE_URL
|
|
auth_token = get_secret("KREA_API_KEY")
|
|
if not auth_token:
|
|
return error_factory("krea", aspect)(
|
|
"KREA_API_KEY not set. Run `hermes tools` → Image "
|
|
"Generation → Krea to configure, get a key at "
|
|
"https://www.krea.ai/settings/api-tokens, or sign in to "
|
|
"a Nous account with the managed Krea gateway enabled "
|
|
"(`hermes setup`).",
|
|
"auth_required",
|
|
)
|
|
|
|
model_id, meta = _resolve_model(kwargs.get("model"))
|
|
creativity = _resolve_creativity(kwargs.get("creativity"))
|
|
fail = error_factory("krea", aspect, model=model_id, prompt=prompt)
|
|
|
|
styles = kwargs.get("styles")
|
|
moodboards = kwargs.get("moodboards")
|
|
styles = styles if isinstance(styles, list) and styles else None
|
|
moodboards = moodboards if isinstance(moodboards, list) and moodboards else None
|
|
|
|
# The managed gateway only prices base text-to-image and URL style
|
|
# references; LoRAs and moodboards are rejected there, so fail fast
|
|
# with guidance instead of a raw 400.
|
|
if managed is not None:
|
|
for present, what, arg in (
|
|
(styles, "trained styles (LoRAs)", "styles"),
|
|
(moodboards, "moodboards", "moodboards"),
|
|
):
|
|
if present:
|
|
return fail(
|
|
f"Managed Krea (Nous Subscription) does not support {what}. "
|
|
f"Set KREA_API_KEY to use Krea directly, or omit `{arg}`.",
|
|
"unsupported_argument",
|
|
)
|
|
|
|
payload: Dict[str, Any] = {
|
|
"prompt": prompt,
|
|
"aspect_ratio": krea_ar,
|
|
"resolution": DEFAULT_RESOLUTION,
|
|
"creativity": creativity,
|
|
}
|
|
seed = kwargs.get("seed")
|
|
if isinstance(seed, int):
|
|
payload["seed"] = seed
|
|
if styles:
|
|
payload["styles"] = styles
|
|
if style_refs:
|
|
# Krea requires objects ({"url", "strength"}) — a bare string yields
|
|
# a 422 "Expected object, received string". Object refs pass through.
|
|
payload["image_style_references"] = [
|
|
{"url": ref, "strength": _DEFAULT_STYLE_REFERENCE_STRENGTH}
|
|
if isinstance(ref, str) else ref
|
|
for ref in style_refs
|
|
]
|
|
if moodboards:
|
|
# Krea currently caps at 1 moodboard per request.
|
|
payload["moodboards"] = moodboards[:1]
|
|
|
|
# 1. Submit job.
|
|
submit_url = f"{base_url}/generate/image/krea/krea-2/{meta['path']}"
|
|
try:
|
|
response = requests.post(
|
|
submit_url,
|
|
headers=_headers(auth_token, managed=managed is not None, json_body=True),
|
|
json=payload, timeout=30,
|
|
)
|
|
response.raise_for_status()
|
|
except requests.HTTPError as exc:
|
|
resp = exc.response
|
|
status = resp.status_code if resp is not None else 0
|
|
err_msg = _submit_error_message(resp, exc)
|
|
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(f"Krea image generation failed ({status}): {err_msg}", "api_error")
|
|
except requests.Timeout:
|
|
return fail("Krea submit timed out (30s)", "timeout")
|
|
except requests.ConnectionError as exc:
|
|
return fail(f"Krea connection error: {exc}", "connection_error")
|
|
|
|
try:
|
|
submit_body = response.json()
|
|
except Exception as exc: # noqa: BLE001
|
|
return fail(f"Krea returned invalid JSON on submit: {exc}", "invalid_response")
|
|
|
|
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")
|
|
|
|
# 2. Poll for completion. Polling is bound to the same principal at the
|
|
# gateway, so the managed path polls the gateway's ``/jobs/{id}`` with
|
|
# the Nous token (404 on cross-user/unknown jobs).
|
|
poll_errors: List[Dict[str, Any]] = []
|
|
|
|
def poll_error(kind: str, detail: Any) -> Dict[str, Any]:
|
|
if kind == "http":
|
|
err = fail(f"Krea poll failed ({detail}) for job {job_id}", "api_error")
|
|
elif kind == "timeout":
|
|
err = fail(f"Krea poll timed out for job {job_id}: {detail}", "timeout")
|
|
elif kind == "invalid_json":
|
|
err = fail(f"Krea poll returned invalid JSON: {detail}", "invalid_response")
|
|
else:
|
|
err = fail(
|
|
f"Krea job {job_id} did not complete within "
|
|
f"{int(_POLL_TIMEOUT_SECONDS)}s (last status: {detail or 'unknown'})",
|
|
"timeout",
|
|
)
|
|
poll_errors.append(err)
|
|
return err
|
|
|
|
job = _poll_krea_job(base_url, auth_token, job_id, on_error=poll_error)
|
|
if poll_errors:
|
|
return poll_errors[0]
|
|
if not isinstance(job, dict):
|
|
return fail("Krea returned non-dict job body", "invalid_response")
|
|
|
|
# 3. Terminal — extract result.
|
|
last_status = job.get("status")
|
|
if last_status == "failed":
|
|
err = (job.get("result") or {}).get("error") if isinstance(job.get("result"), dict) else None
|
|
return fail(f"Krea job {job_id} failed: {err or 'unknown error'}", "api_error")
|
|
if last_status == "cancelled":
|
|
return fail(f"Krea job {job_id} was cancelled", "cancelled")
|
|
if not isinstance(job.get("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")
|
|
|
|
# Krea Enhance pass. Precedence: explicit kwarg > ``image_gen.krea.upscale``
|
|
# config > per-model catalog default. Best-effort: failure falls back to
|
|
# the original image rather than failing the generation.
|
|
upscaled = False
|
|
upscale_requested = kwargs.get("upscale")
|
|
if not isinstance(upscale_requested, bool):
|
|
cfg_upscale = _krea_section().get("upscale")
|
|
upscale_requested = (
|
|
cfg_upscale if isinstance(cfg_upscale, bool) else bool(meta.get("upscale", False))
|
|
)
|
|
if upscale_requested:
|
|
enhanced_url = _enhance_image(
|
|
base_url, auth_token, result_image_url, prompt, managed=managed is not None,
|
|
)
|
|
if enhanced_url:
|
|
result_image_url = enhanced_url
|
|
upscaled = True
|
|
else:
|
|
logger.warning("Krea Enhance pass failed — returning native-resolution image")
|
|
|
|
# Materialise locally — Krea result URLs may expire.
|
|
try:
|
|
image_ref = str(save_url_image(result_image_url, prefix=f"krea_{model_id}"))
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning(
|
|
"Krea image URL %s could not be cached (%s); falling back to bare URL.",
|
|
result_image_url, exc,
|
|
)
|
|
image_ref = result_image_url
|
|
|
|
extra: Dict[str, Any] = {
|
|
"krea_aspect_ratio": krea_ar,
|
|
"resolution": DEFAULT_RESOLUTION,
|
|
"creativity": creativity,
|
|
"job_id": job_id,
|
|
"upscaled": upscaled,
|
|
}
|
|
if upscaled:
|
|
extra["upscale_factor"] = _ENHANCE_SCALE_FACTOR
|
|
if isinstance(job.get("completed_at"), str):
|
|
extra["completed_at"] = job["completed_at"]
|
|
|
|
return success_response(
|
|
image=image_ref,
|
|
model=model_id,
|
|
prompt=prompt,
|
|
aspect_ratio=aspect,
|
|
provider="krea",
|
|
modality=modality,
|
|
extra=extra,
|
|
)
|
|
|
|
|
|
def register(ctx) -> None:
|
|
"""Plugin entry point — wire ``KreaImageGenProvider`` into the registry."""
|
|
ctx.register_image_gen_provider(KreaImageGenProvider())
|