Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@

CUSTOMIZATION_CONTRIBUTORS_GROUP = "nemo.customization.contributors"

# Maps entry-point group → short surface label shown in manifests / UI.
_GROUP_TO_SURFACE: dict[str, str] = {
"nemo.services": "service",
"nemo.cli": "cli",
"nemo.jobs": "jobs",
"nemo.functions": "functions",
"nemo.controllers": "controller",
"nemo.sdk": "sdk",
"nemo.mcp": "mcp",
"nemo.studio": "studio",
"nemo.skills": "skills",
"nemo.docs": "docs",
"nemo.executors": "executor",
"nemo.inference_middleware": "middleware",
"nemo.customization.contributors": "customization",
"nemo.seed": "seed",
"nemo.authz": "authz",
}


def _manifest_plugin_name(group: str, entry_point_name: str) -> str:
if group in _DOT_SCOPED_GROUPS:
Expand Down Expand Up @@ -206,34 +225,46 @@ def discover_manifests() -> dict[str, PluginManifest]:
No ``nemo.plugins`` entry point is required. Each unique name found across
any surface group becomes one :class:`~nemo_platform_plugin.interface.PluginManifest`.
``version`` and ``description`` are read from the installing distribution's
package metadata (``Version`` / ``Summary`` fields).
package metadata (``Version`` / ``Summary`` fields). ``surfaces`` is the
sorted list of short surface labels the plugin contributes to (e.g.
``["cli", "mcp", "service"]``).

Entry-point values are **not loaded** — this function is cheap and has no
import side-effects.
"""
manifests: dict[str, PluginManifest] = {}
# First pass: collect metadata and surfaces per plugin name.
plugin_meta: dict[str, tuple[str, str]] = {} # name → (version, description)
plugin_surfaces: dict[str, set[str]] = {}

for group in _ALL_SURFACE_GROUPS:
surface = _GROUP_TO_SURFACE.get(group, group)
for ep in discover_entry_points(group).values():
plugin_name = _manifest_plugin_name(group, ep.name)
if plugin_name in manifests:
continue
try:
dist = ep.dist
# ``dist.metadata`` is ``email.message.Message``-compatible
# and supports ``.get`` at runtime; ty's stub for
# ``importlib.metadata.PackageMetadata`` doesn't expose it.
version = dist.metadata.get("Version", "") if dist is not None else "" # ty: ignore[unresolved-attribute]
description = dist.metadata.get("Summary", "") if dist is not None else "" # ty: ignore[unresolved-attribute]
except Exception:
version = ""
description = ""
manifests[plugin_name] = PluginManifest(
name=plugin_name,
version=version,
description=description,
)
logger.debug("Discovered plugin %r (v%s) via %r", plugin_name, version, group)
if plugin_name not in plugin_meta:
try:
dist = ep.dist
# ``dist.metadata`` is ``email.message.Message``-compatible
# and supports ``.get`` at runtime; ty's stub for
# ``importlib.metadata.PackageMetadata`` doesn't expose it.
version = dist.metadata.get("Version", "") if dist is not None else "" # ty: ignore[unresolved-attribute]
description = dist.metadata.get("Summary", "") if dist is not None else "" # ty: ignore[unresolved-attribute]
except Exception:
version = ""
description = ""
plugin_meta[plugin_name] = (version, description)
plugin_surfaces[plugin_name] = set()
plugin_surfaces[plugin_name].add(surface)

manifests: dict[str, PluginManifest] = {}
for plugin_name, (version, description) in plugin_meta.items():
surfaces = sorted(plugin_surfaces[plugin_name])
manifests[plugin_name] = PluginManifest(
name=plugin_name,
version=version,
description=description,
surfaces=surfaces,
)
logger.debug("Discovered plugin %r (v%s) surfaces=%r", plugin_name, version, surfaces)

return manifests

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ class PluginManifest:
name: Entry-point key (e.g. ``"example"``).
version: Distribution ``Version`` field, or ``""`` if unavailable.
description: Distribution ``Summary`` field, or ``""`` if unavailable.
surfaces: Sorted list of surface labels this plugin contributes to
(e.g. ``["cli", "mcp", "service"]``).
"""

name: str
version: str
description: str = field(default="")
surfaces: list[str] = field(default_factory=list)
1 change: 1 addition & 0 deletions packages/nmp_common/src/nmp/common/auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def _embedded_pdp_base_url_hint(config: AuthConfig) -> str:
"/health/live",
"/health/ready",
"/metrics",
"/plugins",
"/apis/auth/discovery", # Discovery endpoint for CLI/SDK
}

Expand Down
48 changes: 48 additions & 0 deletions packages/nmp_platform_runner/src/nmp/platform_runner/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from __future__ import annotations

import logging
from typing import Literal

from fastapi import APIRouter, HTTPException
from nemo_platform_plugin.discovery import discover_manifests
from nmp.common.controller import ControllerManager
from nmp.common.service import Service
from nmp.platform_runner.schemas import (
Expand All @@ -18,10 +20,13 @@
HealthReadyResponse,
NotReadyServiceInfo,
PlatformStatusResponse,
PluginInfo,
PluginsResponse,
ServiceStatusBreakdown,
)
from nmp.platform_runner.version import get_platform_version, get_revision
from opentelemetry.semconv.attributes import service_attributes
from starlette.routing import Route

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -85,6 +90,39 @@ async def status() -> PlatformStatusResponse:
controllers=ControllerStatusBreakdown(healthy=all_healthy, status=controllers),
)

@router.get("/plugins", operation_id="platform_plugins", response_model=PluginsResponse)
async def plugins() -> PluginsResponse:

manifests = discover_manifests()
ready, not_ready_list = await _get_service_status_breakdown(services)
ready_set = set(ready)
not_ready_set = {item["name"] for item in not_ready_list}

plugin_list: list[PluginInfo] = []
for manifest in manifests.values():
if manifest.name in ready_set:
status: Literal["ready", "not_ready", "unknown", "installed"] = "ready"
elif manifest.name in not_ready_set:
status = "not_ready"
elif "service" not in manifest.surfaces:
# Plugin has no service entry point — contributes CLI, MCP, Studio, etc.
# There is no service health to check.
status = "installed"
else:
status = "unknown"
plugin_list.append(
PluginInfo(
name=manifest.name,
version=manifest.version,
description=manifest.description,
status=status,
surfaces=manifest.surfaces,
)
)

plugin_list.sort(key=lambda p: p.name)
return PluginsResponse(plugins=plugin_list)

@router.get("/health/live", operation_id="platform_health_live", response_model=HealthLiveResponse)
async def health_live() -> HealthLiveResponse:
return HealthLiveResponse()
Expand All @@ -99,3 +137,13 @@ async def health_ready() -> HealthReadyResponse:
return HealthReadyResponse()

return router


def get_platform_health_endpoint_paths(services: list[Service] | None = None) -> tuple[str, ...]:
"""Return OpenAPI path strings registered on the platform health router."""
router = create_platform_health_router(services or [])
paths: set[str] = set()
for route in router.routes:
if isinstance(route, Route):
paths.add(route.path)
return tuple(sorted(paths))
12 changes: 12 additions & 0 deletions packages/nmp_platform_runner/src/nmp/platform_runner/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,15 @@ class PlatformStatusResponse(BaseModel):
controllers: ControllerStatusBreakdown = Field(
default_factory=lambda: ControllerStatusBreakdown(healthy=True, status={})
)


class PluginInfo(BaseModel):
name: str
version: str
description: str
status: Literal["ready", "not_ready", "unknown", "installed"]
surfaces: list[str] = Field(default_factory=list)


class PluginsResponse(BaseModel):
plugins: list[PluginInfo]
14 changes: 14 additions & 0 deletions packages/nmp_platform_runner/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from nmp.platform_runner.health import get_platform_health_endpoint_paths


def test_get_platform_health_endpoint_paths():
assert get_platform_health_endpoint_paths() == (
"/cluster-info",
"/health/live",
"/health/ready",
"/plugins",
"/status",
)
11 changes: 3 additions & 8 deletions script/generate_openapi_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import yaml
from nmp.common.api.utils import clear_query_param_schemas, register_query_param_schemas
from nmp.common.version import platform_api_version
from nmp.platform_runner.health import get_platform_health_endpoint_paths
from uvicorn.importer import import_from_string

from .openapi_helper.openapi_tools import (
Expand Down Expand Up @@ -425,14 +426,8 @@ def apply_schema_fixes(spec_files: List[str], apply_reorder: bool = True) -> Non
"""Apply schema fixes to a list of OpenAPI spec files."""
print_green("=== Applying fixes to OpenAPI schemas ===")
# Endpoints stripped from the public OpenAPI spec (not exposed in SDK).
# Includes health/status and internal endpoints.
health_endpoints = [
"/status",
"/metrics",
"/cluster-info",
"/health/live",
"/health/ready",
]
# Platform health routes come from health.py; /metrics is from observability tooling.
health_endpoints = list(get_platform_health_endpoint_paths()) + ["/metrics"]

for spec_file in spec_files:
if os.path.exists(spec_file):
Expand Down
Loading