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:
@@ -7,6 +7,7 @@ Phase K.1: Core Infrastructure
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
@@ -2927,7 +2928,14 @@ async def dashboard_sites_view(request: Request) -> Response:
|
||||
import json
|
||||
|
||||
from core.config_snippets import get_supported_clients
|
||||
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
|
||||
from core.site_api import (
|
||||
SITE_PROVIDERS,
|
||||
get_user_credential_fields,
|
||||
get_user_plugin_names,
|
||||
get_user_site,
|
||||
list_site_providers_set,
|
||||
site_supports_provider_keys,
|
||||
)
|
||||
from core.tool_access import get_scope_presets_for_plugin
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
@@ -2946,6 +2954,97 @@ async def dashboard_sites_view(request: Request) -> Response:
|
||||
plugin_names = get_user_plugin_names()
|
||||
scope_presets = get_scope_presets_for_plugin(site["plugin_type"])
|
||||
|
||||
# F.5a.9.x: per-site AI provider keys — only surface the section on
|
||||
# WordPress/WooCommerce sites (the only consumers of
|
||||
# wordpress_generate_and_upload_image).
|
||||
provider_keys_supported = site_supports_provider_keys(site["plugin_type"])
|
||||
provider_key_rows: list[dict[str, Any]] = []
|
||||
if provider_keys_supported:
|
||||
configured = await list_site_providers_set(site_id)
|
||||
# F.X.fix-pass3: pull each row's default_model so the UI can
|
||||
# render a "Set default" pill that's pre-highlighted on the
|
||||
# current selection. One query, all rows.
|
||||
default_models: dict[str, str | None] = {}
|
||||
try:
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
rows = await db.list_site_provider_keys(site_id)
|
||||
for r in rows:
|
||||
default_models[r["provider"]] = r.get("default_model")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("default_model lookup skipped site=%s: %s", site_id, exc)
|
||||
_provider_labels = {
|
||||
"openai": "OpenAI",
|
||||
"stability": "Stability AI",
|
||||
"replicate": "Replicate",
|
||||
"openrouter": "OpenRouter (Gemini / multi-model)",
|
||||
}
|
||||
for provider in SITE_PROVIDERS:
|
||||
provider_key_rows.append(
|
||||
{
|
||||
"provider": provider,
|
||||
"label": _provider_labels.get(provider, provider.title()),
|
||||
"status": "set" if provider in configured else "unset",
|
||||
"default_model": default_models.get(provider),
|
||||
}
|
||||
)
|
||||
|
||||
# F.20 prep: companion-plugin download hint, shown on WP/WC site
|
||||
# pages only. See dashboard_service_page for the matching hint on
|
||||
# the services catalogue.
|
||||
companion_download_url = None
|
||||
if site["plugin_type"] in {"wordpress", "woocommerce"}:
|
||||
companion_download_url = (
|
||||
"https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
|
||||
)
|
||||
|
||||
# F.7e: run the capability probe server-side so the manage page can
|
||||
# render the badge on first paint without a second round-trip. Any
|
||||
# failure falls through to "probe unavailable" so the page always
|
||||
# loads. The frontend Re-check button hits the same endpoint with
|
||||
# force=1 to bypass the 10-min cache.
|
||||
capability_probe = None
|
||||
try:
|
||||
from core.capability_probe import evaluate_tier_fit, probe_site_capabilities
|
||||
|
||||
probe_payload = await probe_site_capabilities(
|
||||
site_id=site_id, user_id=user_session["user_id"]
|
||||
)
|
||||
fit = evaluate_tier_fit(
|
||||
plugin_type=site["plugin_type"],
|
||||
tier=site.get("tool_scope"),
|
||||
probe_payload=probe_payload,
|
||||
)
|
||||
capability_probe = {**probe_payload, "fit": fit}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("capability probe render failed for %s: %s", site_id, exc)
|
||||
capability_probe = {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "unavailable",
|
||||
"reason": f"render_error: {exc}",
|
||||
"fit": {"status": "probe_unavailable", "required": [], "missing": []},
|
||||
}
|
||||
|
||||
# F.X.fix-pass5 — surface which credential fields are *currently
|
||||
# stored* (not the values themselves) so the form can show a
|
||||
# "✓ Stored" badge and require an explicit "Clear" action to
|
||||
# remove an existing value, instead of silently wiping it on a
|
||||
# blank-input save.
|
||||
cred_states: dict[str, bool] = {}
|
||||
try:
|
||||
from core.encryption import get_credential_encryption
|
||||
|
||||
encryptor = get_credential_encryption()
|
||||
decrypted = encryptor.decrypt_credentials(site["credentials"], site_id)
|
||||
for field in plugin_fields.get(site["plugin_type"], []):
|
||||
value = decrypted.get(field["name"])
|
||||
cred_states[field["name"]] = bool(value and str(value).strip())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("cred_states lookup failed for site %s: %s", site_id, exc)
|
||||
cred_states = {}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/sites/manage.html",
|
||||
@@ -2962,6 +3061,12 @@ async def dashboard_sites_view(request: Request) -> Response:
|
||||
"mcp_url": mcp_url,
|
||||
"clients": get_supported_clients(),
|
||||
"current_page": "my_sites",
|
||||
"provider_keys_supported": provider_keys_supported,
|
||||
"provider_key_rows": provider_key_rows,
|
||||
"companion_download_url": companion_download_url,
|
||||
"capability_probe": capability_probe,
|
||||
"cred_states": cred_states,
|
||||
"cred_states_json": json.dumps(cred_states),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3337,15 +3442,24 @@ async def api_list_site_tools(request: Request) -> Response:
|
||||
return err
|
||||
assert site is not None
|
||||
|
||||
from core.site_api import list_site_providers_set
|
||||
from core.tool_access import get_tool_access_manager
|
||||
|
||||
access = get_tool_access_manager()
|
||||
tools = await access.list_tools_for_site(site["id"], site["plugin_type"])
|
||||
# F.X.fix #8: expose the site's configured AI-provider set so the
|
||||
# Tool Access template can render "Configure key for X" CTAs
|
||||
# without issuing a second /api/sites/{id}/provider-keys call.
|
||||
try:
|
||||
configured_providers = sorted(await list_site_providers_set(site["id"]))
|
||||
except Exception: # noqa: BLE001
|
||||
configured_providers = []
|
||||
return JSONResponse(
|
||||
{
|
||||
"site_id": site["id"],
|
||||
"plugin_type": site["plugin_type"],
|
||||
"tool_scope": site.get("tool_scope", "admin"),
|
||||
"configured_providers": configured_providers,
|
||||
"tools": tools,
|
||||
}
|
||||
)
|
||||
@@ -3729,6 +3843,15 @@ async def dashboard_service_page(request: Request) -> Response:
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
# F.20 prep: dashboard UX hint — surface the companion-plugin download
|
||||
# link on the WP / WC service pages. The link switches from the GitHub
|
||||
# raw-download URL to wp.org once the plugin ships (see F.20 scope).
|
||||
companion_download_url = None
|
||||
if plugin_type in {"wordpress", "woocommerce"}:
|
||||
companion_download_url = (
|
||||
"https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/service.html",
|
||||
@@ -3740,10 +3863,206 @@ async def dashboard_service_page(request: Request) -> Response:
|
||||
"display_info": display_info,
|
||||
"current_page": "services",
|
||||
"service": data,
|
||||
"companion_download_url": companion_download_url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# F.18.8 — Provider Keys Dashboard UI (REMOVED in F.5a.9.x)
|
||||
# ======================================================================
|
||||
#
|
||||
# Per-user AI provider keys and the /dashboard/provider-keys page have
|
||||
# been replaced by per-site keys defined in each WordPress / WooCommerce
|
||||
# site's Connection Settings. The site-scoped API is the F.5a.9.x block
|
||||
# below. The user_provider_keys table is dropped in schema migration v12.
|
||||
#
|
||||
# Deleted helpers / handlers:
|
||||
# _PROVIDER_LABELS, _build_provider_rows,
|
||||
# dashboard_provider_keys_page,
|
||||
# api_user_provider_keys_set, api_user_provider_keys_delete.
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# F.5a.9.x — Per-site AI provider key endpoints
|
||||
# ======================================================================
|
||||
|
||||
|
||||
async def api_site_provider_keys_list(request: Request) -> Response:
|
||||
"""GET /api/sites/{id}/provider-keys — list providers with a key stored.
|
||||
|
||||
Returns ``{"providers": ["openai", ...]}``. Does not leak ciphertext
|
||||
or plaintext.
|
||||
"""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect or user_session is None:
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
|
||||
site_id = (request.path_params.get("id") or "").strip()
|
||||
from core.site_api import get_user_site, list_site_providers_set, site_supports_provider_keys
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
|
||||
if not site_supports_provider_keys(site["plugin_type"]):
|
||||
return JSONResponse({"ok": False, "error": "plugin_unsupported"}, status_code=400)
|
||||
|
||||
providers = sorted(await list_site_providers_set(site_id))
|
||||
return JSONResponse({"ok": True, "providers": providers})
|
||||
|
||||
|
||||
async def api_site_provider_keys_set(request: Request) -> Response:
|
||||
"""POST /api/sites/{id}/provider-keys/{provider} — upsert a per-site key.
|
||||
|
||||
Body: ``{"api_key": "..."}``. Enforces site ownership, plugin-type gate,
|
||||
and provider allow-list (site_api.set_site_provider_key).
|
||||
"""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect or user_session is None:
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
|
||||
site_id = (request.path_params.get("id") or "").strip()
|
||||
provider = (request.path_params.get("provider") or "").strip().lower()
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"ok": False, "error": "invalid_body"}, status_code=400)
|
||||
|
||||
api_key = str(body.get("api_key") or "").strip()
|
||||
if len(api_key) < 8:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "key_too_short",
|
||||
"message": "api_key must be at least 8 characters",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
from core.site_api import set_site_provider_key
|
||||
|
||||
try:
|
||||
await set_site_provider_key(site_id, user_session["user_id"], provider, api_key)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "invalid_request", "message": str(exc)},
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"site_provider_keys set failed user=%s site=%s provider=%s: %s",
|
||||
user_session["user_id"],
|
||||
site_id,
|
||||
provider,
|
||||
exc,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "storage_failed", "message": str(exc)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"site_id": site_id,
|
||||
"provider": provider,
|
||||
"secret_last4": api_key[-4:],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_site_provider_keys_delete(request: Request) -> Response:
|
||||
"""DELETE /api/sites/{id}/provider-keys/{provider} — remove a per-site key."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect or user_session is None:
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
|
||||
site_id = (request.path_params.get("id") or "").strip()
|
||||
provider = (request.path_params.get("provider") or "").strip().lower()
|
||||
|
||||
from core.site_api import delete_site_provider_key
|
||||
|
||||
try:
|
||||
deleted = await delete_site_provider_key(site_id, user_session["user_id"], provider)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"site_provider_keys delete failed user=%s site=%s provider=%s: %s",
|
||||
user_session["user_id"],
|
||||
site_id,
|
||||
provider,
|
||||
exc,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "storage_failed", "message": str(exc)},
|
||||
status_code=500,
|
||||
)
|
||||
return JSONResponse({"ok": True, "site_id": site_id, "provider": provider, "deleted": deleted})
|
||||
|
||||
|
||||
async def api_site_provider_default_model(request: Request) -> Response:
|
||||
"""PATCH /api/sites/{id}/provider-keys/{provider}/default-model.
|
||||
|
||||
Body: ``{"model": "<id>"}`` to set, ``{"model": null}`` (or empty
|
||||
string) to clear. F.X.fix-pass3 — lets the user pin a discovered
|
||||
OpenRouter image-model id as the implicit default for a site, so
|
||||
MCP callers don't have to pass ``model=`` every time.
|
||||
"""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect or user_session is None:
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
|
||||
site_id = (request.path_params.get("id") or "").strip()
|
||||
provider = (request.path_params.get("provider") or "").strip().lower()
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"ok": False, "error": "invalid_body"}, status_code=400)
|
||||
|
||||
raw = body.get("model")
|
||||
model: str | None
|
||||
if raw is None:
|
||||
model = None
|
||||
elif isinstance(raw, str):
|
||||
stripped = raw.strip()
|
||||
model = stripped or None
|
||||
else:
|
||||
return JSONResponse({"ok": False, "error": "invalid_model"}, status_code=400)
|
||||
|
||||
from core.site_api import get_user_site
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
|
||||
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
updated = await db.set_site_provider_default_model(site_id, provider, model)
|
||||
if not updated:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "provider_key_not_found",
|
||||
"message": "Save the provider key first, then set the default model.",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"site_id": site_id,
|
||||
"provider": provider,
|
||||
"default_model": model,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def register_dashboard_routes(mcp):
|
||||
"""
|
||||
Register dashboard routes with the MCP server.
|
||||
@@ -3848,6 +4167,15 @@ def register_dashboard_routes(mcp):
|
||||
mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site)
|
||||
mcp.custom_route("/api/sites/{id}", methods=["PATCH"])(api_update_site)
|
||||
|
||||
# Per-site AI provider keys (F.5a.9.x)
|
||||
mcp.custom_route("/api/sites/{id}/provider-keys", methods=["GET"])(api_site_provider_keys_list)
|
||||
mcp.custom_route("/api/sites/{id}/provider-keys/{provider}", methods=["POST"])(
|
||||
api_site_provider_keys_set
|
||||
)
|
||||
mcp.custom_route("/api/sites/{id}/provider-keys/{provider}", methods=["DELETE"])(
|
||||
api_site_provider_keys_delete
|
||||
)
|
||||
|
||||
# User API Key routes (E.3)
|
||||
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)
|
||||
mcp.custom_route("/api/keys", methods=["POST"])(api_create_key)
|
||||
@@ -3867,4 +4195,11 @@ def register_dashboard_routes(mcp):
|
||||
dashboard_user_oauth_clients_delete
|
||||
)
|
||||
|
||||
# F.18.8 /dashboard/provider-keys routes removed in F.5a.9.x —
|
||||
# per-site AI provider keys replace the per-user page. See the
|
||||
# per-site endpoints registered above:
|
||||
# GET /api/sites/{id}/provider-keys
|
||||
# POST /api/sites/{id}/provider-keys/{provider}
|
||||
# DELETE /api/sites/{id}/provider-keys/{provider}
|
||||
|
||||
logger.info("Dashboard routes registered successfully")
|
||||
|
||||
Reference in New Issue
Block a user