feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

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:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

View File

@@ -0,0 +1,154 @@
"""Stability AI image generation provider (Stable Image Core / Ultra).
Uses the v2beta generate endpoint with ``Accept: image/*`` to get raw
bytes back directly (no JSON indirection).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.stability")
_MODEL_ENDPOINTS = {
"core": "https://api.stability.ai/v2beta/stable-image/generate/core",
"ultra": "https://api.stability.ai/v2beta/stable-image/generate/ultra",
"sd3": "https://api.stability.ai/v2beta/stable-image/generate/sd3",
}
_DEFAULT_MODEL = "core"
_MAX_RETRIES = 3
_REQUEST_TIMEOUT = 120
_RETRY_STATUS = {429, 500, 502, 503, 504}
_COST_TABLE: dict[str, float] = {
"core": 0.03,
"ultra": 0.08,
"sd3": 0.065,
}
def _size_to_aspect(size: str) -> str:
"""Map a WxH size string to the Stability aspect_ratio enum."""
mapping = {
"1024x1024": "1:1",
"1152x896": "21:9",
"1216x832": "3:2",
"1344x768": "16:9",
"768x1344": "9:16",
"832x1216": "2:3",
"1024x576": "16:9",
"512x512": "1:1",
}
return mapping.get(size, "1:1")
class StabilityProvider(BaseImageProvider):
name = "stability"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "Stability API key is missing.")
model = (request.model or _DEFAULT_MODEL).lower()
endpoint = _MODEL_ENDPOINTS.get(model)
if endpoint is None:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"Unknown Stability model '{model}'. " f"Allowed: {', '.join(_MODEL_ENDPOINTS)}.",
)
form = aiohttp.FormData()
form.add_field("prompt", request.prompt)
form.add_field("output_format", "png")
form.add_field("aspect_ratio", _size_to_aspect(request.size))
if request.negative_prompt:
form.add_field("negative_prompt", request.negative_prompt)
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "image/*",
}
data, meta = await self._post_with_retry(endpoint, form, headers)
meta = dict(meta)
meta["model"] = model
meta["aspect_ratio"] = _size_to_aspect(request.size)
return GenerationResult(
data=data,
mime="image/png",
filename=f"stability-{model}.png",
meta=meta,
cost_usd=_COST_TABLE.get(model),
)
async def _post_with_retry(
self,
endpoint: str,
form: aiohttp.FormData,
headers: dict[str, str],
) -> tuple[bytes, dict[str, Any]]:
last_error: str = ""
delay = 1.0
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with session.post(endpoint, data=form, headers=headers) as resp:
if resp.status == 200:
body = await resp.read()
return body, {"attempt": attempt, "status": 200}
text = await resp.text()
last_error = text[:500]
if resp.status == 401 or resp.status == 403:
raise ProviderError(
"PROVIDER_AUTH",
f"Stability rejected auth ({resp.status}).",
{"body": last_error},
)
if resp.status == 400:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"Stability rejected request: {last_error}",
{"body": last_error},
)
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning("Stability %d, retry %d", resp.status, attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"Stability quota/rate-limit hit.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Stability upstream error HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
except TimeoutError as exc:
last_error = f"timeout: {exc}"
if attempt < _MAX_RETRIES:
await asyncio.sleep(delay)
delay *= 2
continue
raise ProviderError(
"PROVIDER_TIMEOUT",
"Stability request timed out after retries.",
) from exc
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Stability call failed after {_MAX_RETRIES} attempts: {last_error}",
)