"""F.5a.4: AI image generation + upload chain. Provides ``wordpress_generate_and_upload_image`` which chains: 1. Resolve the caller's per-user provider API key (or env fallback). 2. Call the chosen provider (OpenAI / Stability / Replicate) and get raw bytes. 3. Reuse the F.5a.1 raw-upload path (optimize → POST /wp/v2/media). 4. Optionally apply metadata (alt/caption/title), attach to a post, or set as featured. 5. Emit an audit-log entry with cost/provider/usage. The tool is registered on the WordPress plugin because the end result is a WordPress media item — the ``ai_image`` package is a pure provider library, not an MCP plugin with its own endpoints. """ from __future__ import annotations import hashlib import json import logging import os import time from typing import Any from plugins.ai_image.providers.base import GenerationRequest, ProviderError from plugins.ai_image.registry import get_provider, list_providers from plugins.wordpress.client import WordPressClient from plugins.wordpress.handlers._media_core import wp_raw_upload from plugins.wordpress.handlers._media_security import UploadError from plugins.wordpress.handlers.media import ( _apply_metadata_and_attach, _format_upload_result, _maybe_optimize, ) _logger = logging.getLogger("mcphub.wordpress.ai_media") def _content_sha(data: bytes) -> str: """SHA-256 of the raw generated bytes, truncated for brevity. Two independent provider calls for the same prompt produce different bytes (diffusion models are non-deterministic), so this hash is effectively a per-call fingerprint. Including it in the idempotency key means a retry with the *same already-generated* bytes dedupes, while a fresh call with a new image gets a new id. """ return hashlib.sha256(data).hexdigest()[:32] def _idempotency_key_for( *, provider: str, model: str | None, prompt: str, size: str, attach_to_post: int | None, set_featured: bool, site_url: str, user_id: str | None, content_sha: str, ) -> str: """Build the ``Idempotency-Key`` header value for an AI upload. Matches the ``^[A-Za-z0-9_\\-]{1,128}$`` regex the companion validates (see airano-mcp-bridge.php handle_upload_and_attach). """ raw = "|".join( [ provider or "", model or "", prompt or "", size or "", str(attach_to_post or 0), "1" if set_featured else "0", site_url or "", user_id or "", content_sha or "", ] ) digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() return f"mcphub_ai_{digest[:48]}" def get_tool_specifications() -> list[dict[str, Any]]: return [ { "name": "generate_and_upload_image", "method_name": "generate_and_upload_image", "description": ( "Generate an image with an AI provider (OpenAI DALL-E, " "Stability, or Replicate Flux) and upload it to the WordPress " "media library in one call. Optionally attach to a post or set " "as featured image. Uses the caller's stored provider API key; " "falls back to server env vars if no per-user key is set." ), "schema": { "type": "object", "properties": { "provider": { "type": "string", "enum": list_providers(), "description": "AI provider to use.", }, "prompt": { "type": "string", "description": "Text prompt describing the image to generate.", }, "size": { "type": "string", "default": "1024x1024", "description": "Requested image size (WxH). Providers map to supported sizes.", }, "quality": { "type": "string", "default": "standard", "description": "Provider-specific quality hint (e.g. 'standard'/'hd').", }, "model": { "anyOf": [{"type": "string"}, {"type": "null"}], "description": "Optional model override (e.g. 'dall-e-3', 'flux-dev').", }, "negative_prompt": { "anyOf": [{"type": "string"}, {"type": "null"}], }, "filename": { "anyOf": [{"type": "string"}, {"type": "null"}], "description": "Optional filename hint for the WP library.", }, "title": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "caption": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "attach_to_post": { "anyOf": [{"type": "integer", "minimum": 1}, {"type": "null"}], }, "set_featured": {"type": "boolean", "default": False}, "skip_optimize": {"type": "boolean", "default": False}, "convert_to": { "anyOf": [ {"type": "string", "enum": ["webp", "avif"]}, {"type": "null"}, ], "description": ( "F.5a.8.1: re-encode the generated image as WebP or " "AVIF before upload (falls back to WebP if AVIF is " "unavailable)." ), }, }, "required": ["provider", "prompt"], }, "scope": "write", } ] class AIMediaHandler: """Handles AI-powered image generation + WP upload chain.""" def __init__( self, client: WordPressClient, user_id: str | None = None, site_id: str | None = None, wc_client: WordPressClient | None = None, ): self.client = client # ``user_id`` is injected by the user-endpoint plumbing (F.5a.4). # Admin / system endpoints leave it None. self.user_id = user_id # ``site_id`` is injected by the user-endpoint plumbing (F.5a.9.x) # and is the primary input for resolving per-site provider API # keys. Admin / env-driven endpoints leave it None, in which case # the resolver falls back to the legacy env-var path. self.site_id = site_id # F.X.fix-pass6 — optional separate client for /wc/v3/* (the # WC consumer-key/secret pair on a WC site). Used by # _apply_metadata_and_attach to detect "attach_to_post is a # WC product" and route the featured-image set through # /wc/v3/products/{id}/images instead of /wp/v2/posts/{id} # which 404s for the product CPT. None = same client serves # both REST roots (WP plugin or legacy single-credential WC). self.wc_client = wc_client async def generate_and_upload_image( self, provider: str, prompt: str, size: str = "1024x1024", quality: str = "standard", model: str | None = None, negative_prompt: str | None = None, filename: str | None = None, title: str | None = None, alt_text: str | None = None, caption: str | None = None, attach_to_post: int | None = None, set_featured: bool = False, skip_optimize: bool = False, convert_to: str | None = None, ) -> str: started = time.time() try: from core.tool_rate_limiter import ToolRateLimitError, get_tool_rate_limiter try: get_tool_rate_limiter().check("wordpress_generate_and_upload_image", self.user_id) except ToolRateLimitError as e: return json.dumps(e.to_dict(), indent=2) provider_impl = get_provider(provider) api_key = await self._resolve_api_key(provider) # F.X.fix-pass3 — when caller omitted ``model``, fall back # to the per-site default the operator pinned in the # dashboard. Keeps MCP call sites short and lets the user # rotate models without code changes. if model is None and self.site_id: try: from core.database import get_database db = get_database() model = await db.get_site_provider_default_model(self.site_id, provider) except Exception as exc: # noqa: BLE001 _logger.debug( "default_model lookup skipped site=%s provider=%s: %s", self.site_id, provider, exc, ) if not api_key: if self.site_id: msg = ( f"No API key configured for provider '{provider}' on " f"this site. Open the site in the dashboard and add the " f"key under 'AI Image Generation' in Connection Settings." ) dashboard_url = f"/dashboard/sites/{self.site_id}" else: msg = ( f"No API key configured for provider '{provider}'. " f"Set the {provider.upper()}_API_KEY env var on the server." ) dashboard_url = None payload = { "error_code": "NO_PROVIDER_KEY", "message": msg, "provider": provider, } if dashboard_url: payload["dashboard_url"] = dashboard_url return json.dumps(payload, indent=2) request = GenerationRequest( prompt=prompt, size=size, quality=quality, model=model, negative_prompt=negative_prompt, ) result = await provider_impl.generate(api_key, request) data, mime_hint = _maybe_optimize( result.data, result.mime, skip=skip_optimize, convert_to=convert_to ) # F.X.fix #7: stable idempotency key per logical call. A # client-side retry (after timeout) produces the same # digest, so the companion dedupes instead of creating an # "-2.webp" orphan. Key covers every input that would # change the intended outcome. idempotency_key = _idempotency_key_for( provider=provider, model=result.meta.get("model") or model, prompt=prompt, size=size, attach_to_post=attach_to_post, set_featured=set_featured, site_url=self.client.site_url, user_id=self.user_id, content_sha=_content_sha(data), ) media = await wp_raw_upload( self.client, data, filename=filename or result.filename, mime_hint=mime_hint or result.mime, # F.5a.8.5: single-call upload+metadata+attach+featured # when the companion's upload-and-attach route is # advertised; _apply_metadata_and_attach becomes a no-op. attach_to_post=attach_to_post, set_featured=set_featured, title=title, alt_text=alt_text, caption=caption, idempotency_key=idempotency_key, ) attach_status = await _apply_metadata_and_attach( self.client, media, title=title, alt_text=alt_text, caption=caption, attach_to_post=attach_to_post, set_featured=set_featured, wc_client=self.wc_client, ) duration_ms = int((time.time() - started) * 1000) self._audit( provider=provider, model=result.meta.get("model") or model, size=size, duration_ms=duration_ms, bytes_=len(result.data), cost_usd=result.cost_usd, media_id=media.get("id"), error=None, ) from core.media_audit import log_media_upload log_media_upload( site=self.client.site_url, user_id=self.user_id, mime=media.get("mime_type") or result.mime, size_bytes=len(data), source=f"ai:{provider}", media_id=media.get("id"), cost_usd=result.cost_usd, ) # F.X.fix-pass6 — surface partial-success: media uploaded # but featured-set / metadata-apply may have warnings. Old # behaviour bubbled those exceptions up as # GENERATION_FAILED, hiding the freshly-created media id # and leaving an orphan in the library. payload = _format_upload_result(media, source=f"ai:{provider}") payload["provider"] = provider payload["provider_meta"] = result.meta payload["cost_usd"] = result.cost_usd payload["duration_ms"] = duration_ms payload["attach"] = { "metadata_applied": attach_status.get("metadata_applied", False), "featured_set": attach_status.get("featured_set", False), "featured_context": attach_status.get("featured_context"), } warnings = attach_status.get("warnings") or [] if warnings: payload["warnings"] = warnings return json.dumps(payload, indent=2) except ProviderError as e: self._audit( provider=provider, model=model, size=size, duration_ms=int((time.time() - started) * 1000), bytes_=0, cost_usd=None, media_id=None, error=e.code, ) return json.dumps(e.to_dict(), indent=2) except UploadError as e: self._audit( provider=provider, model=model, size=size, duration_ms=int((time.time() - started) * 1000), bytes_=0, cost_usd=None, media_id=None, error=e.code, ) return json.dumps(e.to_dict(), indent=2) except Exception as e: _logger.exception("generate_and_upload_image failed") return json.dumps( { "error_code": "GENERATION_FAILED", "message": f"generate_and_upload_image failed: {e}", }, indent=2, ) async def _resolve_api_key(self, provider: str) -> str | None: """Resolve the provider key for this call. F.5a.9.x policy (single-source, per-site): 1. ``site_id`` set (per-user endpoint, the only UI-reachable path): read the key from the per-site ``site_provider_keys`` row. Env fallback is deliberately NOT applied here so a single ``OPENAI_API_KEY=...`` on the server cannot silently paper over a missing per-site configuration — callers instead get ``NO_PROVIDER_KEY`` pointing at the site's dashboard page. 2. ``site_id is None`` (admin / master-key endpoint, no site context): fall back to the ``_API_KEY`` env var. This preserves the legacy self-hosted / admin workflow. The per-user provider-keys store from F.18.8 has been removed in favour of the per-site model; there is no hybrid fallback. """ # 1. Per-site key (the primary path) if self.site_id: try: from core.site_api import get_site_provider_key return await get_site_provider_key(self.site_id, provider) except Exception as exc: _logger.error( "Site provider-key lookup failed site=%s provider=%s: %s", self.site_id, provider, exc, ) return None # 2. Admin / env-var fallback (no site context) env_var = f"{provider.upper()}_API_KEY" return os.environ.get(env_var) def _audit( self, *, provider: str, model: str | None, size: str, duration_ms: int, bytes_: int, cost_usd: float | None, media_id: int | None, error: str | None, ) -> None: try: from core.audit_log import get_audit_logger logger_ = get_audit_logger() except Exception: return try: logger_.log_tool_call( tool_name="wordpress_generate_and_upload_image", params={ "provider": provider, "model": model, "size": size, "bytes": bytes_, "cost_usd": cost_usd, "media_id": media_id, }, duration_ms=duration_ms, user_id=self.user_id, error=error, result_summary=( f"{provider}/{model or '-'} {bytes_}B " f"${cost_usd or 0:.4f} -> media_id={media_id}" if not error else f"failed: {error}" ), ) except Exception: _logger.debug("audit log emit failed", exc_info=True)