#!/usr/bin/env python3 """Generate the complete OpenAPI-to-Agent surface audit.""" from __future__ import annotations import json import sys from collections import Counter, defaultdict from pathlib import Path from typing import Any from fastapi import FastAPI PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from app.agent.policy.api import API_OPERATION_ROUTES # noqa: E402 from app.api.apiv1 import api_router # noqa: E402 JSON_OUTPUT = PROJECT_ROOT / "docs/refactor/agent-api-surface-audit.json" MARKDOWN_OUTPUT = PROJECT_ROOT / "docs/refactor/agent-api-surface-audit.md" HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) TRANSPORT_TAGS = frozenset( { "agent", "anthropic", "auth", "llm", "login", "mcp", "message", "mfa", "notification", "openai", "user", "webhook", } ) CONSOLIDATED_TAGS = frozenset( { "anilist", "bangumi", "discover", "douban", "recommend", "tmdb", } ) PROVIDER_PATH_PREFIXES = ( "/api/v1/download/", "/api/v1/mediaserver/", ) CONSOLIDATED_ROUTE_OWNERS: dict[tuple[str, str], str] = { ("POST", "/api/v1/download/"): "download.add", ("GET", "/api/v1/plugin/installed"): "plugin.installed", ("GET", "/api/v1/plugin/source/{plugin_id}/options"): "plugin.source.options", ("GET", "/api/v1/plugin/{plugin_id}"): "plugin.config.get", ("GET", "/api/v1/search/last"): "search.results", ("GET", "/api/v1/search/media/{media_id}/stream"): "search.torrents", ("GET", "/api/v1/search/subtitle/media/{media_id}/stream"): "subtitle.search.media", ("GET", "/api/v1/search/subtitle/title/stream"): "subtitle.search.title", ("GET", "/api/v1/search/title/stream"): "search.title", ("GET", "/api/v1/site/"): "site.list", ("GET", "/api/v1/site/cookie/{site_id}"): "site.cookie.update", ("GET", "/api/v1/site/domain/{site_url}"): "site.list", ("GET", "/api/v1/site/{site_id}"): "site.list", ("POST", "/api/v1/storage/list"): "storage.list", ("GET", "/api/v1/subscribe/list"): "subscription.list", ("GET", "/api/v1/system/env"): "config.system.get", ("POST", "/api/v1/system/env"): "config.system.update", ("GET", "/api/v1/system/global"): "config.system.get", ("GET", "/api/v1/system/setting/{key}"): "config.system.get", ("POST", "/api/v1/system/setting/{key}"): "config.system.update", ("GET", "/api/v1/transfer/now"): "scheduler.run", ("GET", "/api/v1/workflow/"): "workflow.list", ("GET", "/api/v1/media/category"): "media.classification.policy.get", ("GET", "/api/v1/media/category/config"): "media.classification.policy.get", } STREAM_OR_BINARY_PATHS = frozenset( { "/api/v1/plugin/file/{plugin_id}/{filepath}", "/api/v1/site/icon/{site_id}", "/api/v1/storage/download", "/api/v1/storage/image", "/api/v1/system/cache/image", "/api/v1/system/img/{proxy}", "/api/v1/system/logging", "/api/v1/system/logging/download/{name}", "/api/v1/system/message", "/api/v1/system/progress/{process_type}", } ) UI_PRESENTATION_PATHS = frozenset( { "/api/v1/plugin/dashboard/meta", "/api/v1/plugin/dashboard/{plugin_id}", "/api/v1/plugin/dashboard/{plugin_id}/{key}", "/api/v1/plugin/page/{plugin_id}", "/api/v1/plugin/runtime/{plugin_id}/data/summary", "/api/v1/plugin/sidebar_nav", "/api/v1/rule/custom/reorder", "/api/v1/rule/groups/reorder", "/api/v1/storage/catalog", "/api/v1/storage/options", } ) EXPLICIT_TRANSPORT_PATHS = frozenset( { "/api/v1/plugin/remotes", "/api/v1/subscribe/seerr", "/api/v1/system/ping", } ) SUBSCRIPTION_EXECUTION_UI_PREFIX = "/api/v1/subscribe/execution/" MUSIC_LIBRARY_STATUS_UI_PATH = "/api/v1/music/library/status" def _gateway_routes() -> dict[tuple[str, str], list[str]]: """Return exact HTTP routes and every stable gateway operation using them.""" results: dict[tuple[str, str], list[str]] = defaultdict(list) for operation_id, route in API_OPERATION_ROUTES.items(): results[(route.method.upper(), route.path)].append(operation_id) return {key: sorted(values) for key, values in results.items()} def _classify( *, method: str, path: str, tags: list[str], gateway_routes: dict[tuple[str, str], list[str]], ) -> tuple[str, str, str, list[str]]: """把 OpenAPI 操作归入经过审查的 Agent 或直接管理端契约。""" operations = gateway_routes.get((method, path), []) if operations: return ( "gateway", "moviepilot-api", "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", operations, ) primary_tag = tags[0] if tags else "untagged" if primary_tag in TRANSPORT_TAGS: return ( "transport_or_identity", "host-runtime", "Authentication, protocol compatibility, conversation transport, callback, or account lifecycle endpoint; never recursively exposed as an Agent business action.", [], ) consolidated_owner = CONSOLIDATED_ROUTE_OWNERS.get((method, path)) if consolidated_owner: return ( "consolidated", "moviepilot-api", f"This compatibility, broader-response, or UI route is represented by the safer stable operation {consolidated_owner}.", [consolidated_owner], ) if path in STREAM_OR_BINARY_PATHS: return ( "stream_or_binary", "host-transport", "Streaming, image, archive, or file response consumed by a direct client; the structured JSON Agent gateway does not proxy binary or unbounded streams.", [], ) if path in UI_PRESENTATION_PATHS: return ( "ui_presentation", "host-ui", "Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.", [], ) if method == "POST" and path == "/api/v1/history/transfer/{history_id}/cleanup-resolved": return ( "ui_presentation", "host-ui", "Manual confirmation of downloader cleanup is owned by the authenticated history management workflow; it is not an Agent action that verifies external cleanup.", [], ) if path == "/api/v1/history/transfer/{history_id}/discard-corrupt": return ( "ui_presentation", "host-ui", "Discarding corrupt transfer state is owned by the authenticated management recovery workflow; it is not a stable Agent gateway operation.", [], ) if path.startswith(SUBSCRIPTION_EXECUTION_UI_PREFIX): return ( "ui_presentation", "host-ui", "Background subscription execution status and cancellation are owned by the authenticated frontend workflow; they are not yet a stable Agent gateway contract.", [], ) if method == "POST" and path == MUSIC_LIBRARY_STATUS_UI_PATH: return ( "ui_presentation", "host-ui", "Batch music-library presence is a bounded projection for the authenticated artist resource matrix; it is not a standalone Agent business action.", [], ) if path in EXPLICIT_TRANSPORT_PATHS: return ( "transport_or_identity", "host-runtime", "Health, bootstrap, federation, or external webhook transport endpoint; it is not recursively callable as an Agent business action.", [], ) if path.startswith(PROVIDER_PATH_PREFIXES): return ( "provider-skill", "downloader-operation" if "/download/" in path else "mediaserver-operation", "Low-level provider behavior is exposed by the self-describing provider Skill; high-level MoviePilot operations remain in moviepilot-api.", [], ) if primary_tag in CONSOLIDATED_TAGS: return ( "consolidated", "moviepilot-api", "Source-specific or presentation-oriented route is represented by a stable aggregate search, detail, person, recommendation, or music operation instead of duplicating every frontend route.", [], ) if path.endswith("2") or "/schedule2" in path or "/recognize2" in path or "/recognize_file2" in path: return ( "alternate-auth-duplicate", "moviepilot-api", "API-token compatibility duplicate; the Agent uses the corresponding bearer-authenticated operation with its persisted user identity.", [], ) raise ValueError(f"Unclassified OpenAPI operation: {method} {path}") def generate_audit() -> dict[str, Any]: """Build a deterministic entry for every v1 OpenAPI HTTP operation.""" app = FastAPI() app.include_router(api_router, prefix="/api/v1") openapi = app.openapi() gateway_routes = _gateway_routes() entries = [] for path, path_item in sorted(openapi.get("paths", {}).items()): for raw_method, operation in sorted(path_item.items()): method = raw_method.upper() if method not in HTTP_METHODS or not isinstance(operation, dict): continue tags = [str(tag) for tag in operation.get("tags") or []] disposition, owner, reason, operation_ids = _classify( method=method, path=path, tags=tags, gateway_routes=gateway_routes, ) entries.append( { "method": method, "path": path, "tags": tags, "summary": str(operation.get("summary") or ""), "disposition": disposition, "owner": owner, "operation_ids": operation_ids, "reason": reason, } ) counts = Counter(entry["disposition"] for entry in entries) matched_gateway_routes = { (entry["method"], entry["path"]) for entry in entries if entry["disposition"] == "gateway" } dynamic_gateway_routes = [ { "method": method, "path": path, "operation_ids": operation_ids, "reason": ( "The executor validates and expands this bounded source placeholder to one of " "tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route." ), } for (method, path), operation_ids in sorted(gateway_routes.items()) if (method, path) not in matched_gateway_routes ] return { "openapi_operation_count": len(entries), "gateway_operation_count": len(API_OPERATION_ROUTES), "gateway_http_route_count": len(gateway_routes), "matched_gateway_http_route_count": len(matched_gateway_routes), "dynamic_gateway_routes": dynamic_gateway_routes, "disposition_counts": dict(sorted(counts.items())), "operations": entries, } def render_markdown(audit: dict[str, Any]) -> str: """Render the complete audit as a reviewable architecture document.""" lines = [ "# MoviePilot Agent API Surface Audit", "", "> Generated from the v1 FastAPI OpenAPI document and the fixed Agent API registry.", "> Do not edit route rows manually; run `scripts/generate_agent_api_surface_audit.py`.", "", "## Result", "", f"- OpenAPI HTTP operations: **{audit['openapi_operation_count']}**", f"- Stable `moviepilot_api` operations: **{audit['gateway_operation_count']}**", f"- Exact HTTP routes used by the gateway: **{audit['gateway_http_route_count']}**", f"- OpenAPI routes matched directly by the gateway: **{audit['matched_gateway_http_route_count']}**", f"- Bounded dynamic gateway routes: **{len(audit['dynamic_gateway_routes'])}**", "- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.", "- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.", "", "## Dispositions", "", "| disposition | count | meaning |", "| :--- | ---: | :--- |", ] meanings = { "gateway": "Approved structured MoviePilot Agent operation.", "provider-skill": "Low-level downloader or media-server capability owned by a provider Skill.", "consolidated": "Source/UI route represented by a stable aggregate Agent operation.", "alternate-auth-duplicate": "API-token compatibility duplicate of a bearer-authenticated capability.", "transport_or_identity": "Authentication, protocol, callback, account, or conversation transport boundary.", "stream_or_binary": "Streaming or binary response owned by a direct client transport.", "ui_presentation": "Frontend or plugin-rendered presentation contract.", } for disposition, count in audit["disposition_counts"].items(): lines.append(f"| `{disposition}` | {count} | {meanings[disposition]} |") if audit["dynamic_gateway_routes"]: lines.extend( [ "", "## Bounded Dynamic Routes", "", "| method | route template | operations | constraint |", "| :--- | :--- | :--- | :--- |", ] ) for item in audit["dynamic_gateway_routes"]: lines.append( f"| `{item['method']}` | `{item['path']}` | {', '.join(item['operation_ids'])} | {item['reason']} |" ) lines.extend( [ "", "## Complete Route Inventory", "", "| method | path | tags | disposition | owner / operation | summary |", "| :--- | :--- | :--- | :--- | :--- | :--- |", ] ) for item in audit["operations"]: tags = ", ".join(item["tags"]) or "-" owner = ", ".join(item["operation_ids"]) or item["owner"] summary = item["summary"].replace("|", "\\|") lines.append( f"| `{item['method']}` | `{item['path']}` | {tags} | `{item['disposition']}` | {owner} | {summary} |" ) lines.extend( [ "", "## Exposure Rule", "", "Every structured JSON business endpoint is either a stable gateway operation, a provider Skill capability, or an explicitly consolidated compatibility route. Authentication, webhook, stream, binary, and UI-presentation endpoints remain owned by their direct transport or frontend consumer and must not be made recursively callable by the Agent.", "", ] ) return "\n".join(lines) def main() -> int: """Write deterministic JSON and Markdown audit artifacts.""" audit = generate_audit() JSON_OUTPUT.write_text( json.dumps(audit, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) MARKDOWN_OUTPUT.write_text(render_markdown(audit), encoding="utf-8") print(f"generated {JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}") return 0 if __name__ == "__main__": raise SystemExit(main())