feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
462
plugins/wordpress/handlers/ai_media.py
Normal file
462
plugins/wordpress/handlers/ai_media.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""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 ``<PROVIDER>_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)
|
||||
Reference in New Issue
Block a user