Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
47 changes: 27 additions & 20 deletions openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,42 @@

## Generation

To generate the `openapi.yaml` file that corresponds exactly to what is implemented in each microservice, we start each microservice and fetch the `/openapi.json` file generated by the FastAPI server.
To generate the `openapi.yaml` files, the generator imports each FastAPI app in an isolated subprocess and calls its `.openapi()` method directly (no running server), then applies a series of schema fixes and validation passes.

To generate the updated OpenAPI schema, run:

```bash
uv run --frozen python -m script.generate_openapi_spec
```

## OpenAPI Specs Overview
## Output layout

The following table lists all the OpenAPI specifications that are merged into the final `openapi.yaml` file, along with their sources:
The generator no longer emits one spec per microservice and merges them. It now produces:

| Service | Source | Output File |
|---------|---------|-------------|
| Entity Store | Generated from `entity_store.server:app` | `entity-store.openapi.yaml` |
| Evaluator | Generated from `evaluator.server:app` | `evaluator.openapi.yaml` |
| Guardrails | Generated from `guardrails.app:app` | `guardrails.openapi.yaml` |
| Customization | Generated from `nemo-customizer-plugin` contributor routes | `customization.openapi.yaml` |
| Deployment Management | Direct copy from `deployment.openapi.yaml` | `deployment-management.openapi.yaml` |
| Jobs | Generated from `jobs.api.server:app` | `jobs.openapi.yaml` |
| Data Designer | Generated from `data_designer.api.server:app` | `data-designer.openapi.yaml` |
| Auditor | Generated from `auditor.server:app` | `auditor.openapi.yaml` |
| Safe Synthesizer API | Generated from `safe_synthesizer_api.server:app` | `safe-synthesizer.openapi.yaml` |
| Intake | Generated from `src.main:app` | `intake.openapi.yaml` |
| Models | Generated from `models.api.server:app` | `models.openapi.yaml` |
| Inference Gateway | Generated from `inference_gateway.api.server:create_app` | `inference-gateway.openapi.yaml` |
| Platform Common | Generated from `nemoplatform.server:app` | `nmp-common.openapi.yaml` |
**One aggregate platform spec**, built from the platform runner (`nmp.platform_runner.server:create_platform_openapi_app`) with plugin services deliberately excluded (`NEMO_PLUGIN_SERVICES_ALLOWLIST=""` — see `SERVICES` in `script/generate_openapi_spec.py`). This aggregate covers the core platform services (entities, jobs, models, inference gateway, secrets, files, platform-common, etc.) and lands in:

All specs are merged with the `--keep-versions` flag to preserve version information in the final `openapi.yaml`.
| File | Contents |
|------|----------|
| `openapi/openapi.yaml` | Final merged GA + EA platform spec |
| `openapi/ga/openapi.yaml` | GA-only platform spec |
| `openapi/ea/openapi.yaml` | EA-only platform spec |
| `openapi/ga/individual/platform.openapi.yaml` | The platform spec before GA/EA merge |

**One spec per opted-in plugin**, written next to each plugin — never merged into the platform spec. A plugin opts in by declaring a `[tool.nemo.openapi]` table in its own `pyproject.toml`; `discover_plugins()` (`script/openapi_helper/plugin_config.py`) enumerates those, builds each plugin's FastAPI app via the convention loader (or a `factory_override`), and emits:

| Plugin | Output File |
|--------|-------------|
| Agents | `plugins/nemo-agents/openapi/openapi.yaml` |
| Auditor | `plugins/nemo-auditor/openapi/openapi.yaml` |
| Customization | `plugins/nemo-customizer/openapi/openapi.yaml` |
| Data Designer | `plugins/nemo-data-designer/openapi/openapi.yaml` |
| Deployments | `plugins/nemo-deployments/openapi/openapi.yaml` |
| Evaluator | `plugins/nemo-evaluator/openapi/openapi.yaml` |
| Safe Synthesizer | `plugins/nemo-safe-synthesizer/openapi/openapi.yaml` |

The Customization spec is assembled at generation time from whichever customization contributors (`nemo.customization.contributors` entry points — e.g. `automodel`, `rl`, `unsloth`) are installed in the workspace, so its route surface depends on the synced environment. To add a new plugin to this list, add an (empty is fine) `[tool.nemo.openapi]` table to its `pyproject.toml`; if the plugin has more than one `nemo.services` entry point, set `service_name` in that table to disambiguate.

The platform GA and EA specs are merged with the `--keep-versions` flag to preserve version information in the final `openapi.yaml`.

### Conflicts

Expand All @@ -54,4 +61,4 @@ Currently, the examples are manually generated from notebooks. A robust system t

## NOTE

Ideally, the `nmp-common.openapi.yaml` file would be the platform OpenAPI spec directly, but for various reasons, it is not. We should get to that point soon. Until then, we take this merging approach to have a spec that corresponds 100% to what is implemented.
The aggregate platform spec is now generated directly from the platform runner (`nmp.platform_runner.server`), so it corresponds to what is actually mounted at runtime. Only the GA and EA variants of that single platform spec are merged; individual core services are no longer emitted and merged separately.
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
from typing import Any, Callable, ClassVar

import typer
from fastapi import APIRouter
from nemo_platform_plugin.authz import AuthzScope, CallerKind, path_rule
from nemo_platform_plugin.authz import AuthzScope
from nemo_platform_plugin.customization_contributor import CustomizationContributorSDKResources
from nemo_platform_plugin.jobs.api_factory import JobRouteOption
from nemo_platform_plugin.jobs.routes import add_job_routes
Expand Down Expand Up @@ -55,21 +54,18 @@ def _get_config(self) -> Any:
raise NotImplementedError

def get_routers(self) -> list[RouterSpec]:
"""Health endpoint + ``add_job_routes`` for the backend job collection.
"""``add_job_routes`` for the backend job collection.

HTTP authz is derived from the ``@path_rule``-decorated routes: the health
endpoint is authenticated-but-permissionless, and the job collection's
permissions (``customization.<name>.jobs.*``) are stamped onto the factory
routes via the ``customization`` :class:`AuthzScope` (scope ``customization``,
permission namespace deepened to ``customization.<name>.jobs``).
The job collection's permissions (``customization.<name>.jobs.*``) are
stamped onto the factory routes via the ``customization``
:class:`AuthzScope` (scope ``customization``, permission namespace
deepened to ``customization.<name>.jobs``).

Backend health is not exposed per contributor — the customization router
reports a single ``/apis/customization/v2/healthz`` that enumerates the
registered contributors.
"""
config = self._get_config()
router = APIRouter()

@router.get("/healthz")
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[])
async def healthz() -> dict[str, str]:
return {"backend": self.name, "status": "ok"}

jobs_router = add_job_routes(
self.job_cls,
Expand All @@ -81,12 +77,6 @@ async def healthz() -> dict[str, str]:
)

return [
RouterSpec(
router=router,
prefix=f"/v2/workspaces/{{workspace}}/{self.name}",
tag=self._title,
description=f"{self._title} contributor health.",
),
RouterSpec(
router=jobs_router,
prefix="/v2/workspaces/{workspace}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,32 +168,13 @@ def __init__(self, platform: PlatformClient) -> None:
self._platform = platform
self._http_client = platform._client

def _healthz_url(self) -> str:
return url(
self._platform,
f"v2/workspaces/{{workspace}}/{self.backend}/healthz",
self._platform.workspace,
)

def _new_record(self, payload: Any) -> JobRecord:
return self.record_schema.model_validate(payload)


class JobsResource(_JobsResourceBase):
"""Sync SDK namespace at ``client.customization.<backend>.jobs``."""

def plugin_status(self) -> dict[str, object]:
"""Return contributor health from the customization service."""
response = self._http_client.get(
self._healthz_url(),
headers=platform_default_headers(self._platform),
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise TypeError(f"{self.backend} health response must be a JSON object.")
return {str(key): value for key, value in payload.items()}

def create(
self,
spec: BaseModel,
Expand Down Expand Up @@ -241,18 +222,6 @@ def get_job_resource(self, job_name: str, workspace: str | None = None) -> JobRe
class AsyncJobsResource(_JobsResourceBase):
"""Async SDK namespace at ``client.customization.<backend>.jobs``."""

async def plugin_status(self) -> dict[str, object]:
"""Return contributor health from the customization service."""
response = await self._http_client.get(
self._healthz_url(),
headers=platform_default_headers(self._platform),
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise TypeError(f"{self.backend} health response must be a JSON object.")
return {str(key): value for key, value in payload.items()}

async def create(
self,
spec: BaseModel,
Expand Down
20 changes: 9 additions & 11 deletions plugins/nemo-automodel/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,19 @@ def _make_automodel_app() -> FastAPI:
return app


def test_automodel_healthz_under_workspace() -> None:
client = TestClient(_make_automodel_app())
response = client.get("/v2/workspaces/test-ws/automodel/healthz")
assert response.status_code == 200
assert response.json() == {"backend": "automodel", "status": "ok"}


def test_automodel_jobs_collection_path() -> None:
paths = _route_paths(_make_automodel_app())
assert "/v2/workspaces/{workspace}/automodel/jobs" in paths


def test_customization_router_merges_automodel(monkeypatch: pytest.MonkeyPatch) -> None:
def test_contributor_exposes_no_per_backend_healthz() -> None:
"""Backend health is not exposed per contributor; the customization router
reports a single ``/v2/healthz`` that enumerates contributors instead."""
paths = _route_paths(_make_automodel_app())
assert not any(p.endswith("/healthz") for p in paths)


def test_customization_router_healthz_lists_contributors(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nemo_customizer.router.discover_customization_contributors",
lambda: {"automodel": AutomodelContributor()},
Expand All @@ -55,13 +55,11 @@ def test_customization_router_merges_automodel(monkeypatch: pytest.MonkeyPatch)
app.include_router(spec.router, prefix=prefix)

client = TestClient(app)
assert client.get("/healthz").json()["contributors"] == ["automodel"]
assert client.get("/v2/workspaces/ws-a/automodel/healthz").status_code == 200
assert client.get("/v2/healthz").json()["contributors"] == ["automodel"]


def test_workspace_isolation_list_uses_path_segment() -> None:
"""Job routes are under ``/v2/workspaces/{workspace}/automodel/jobs`` — distinct per workspace."""
app = _make_automodel_app()
paths = _route_paths(app)
assert "/v2/workspaces/{workspace}/automodel/jobs" in paths
assert "/v2/workspaces/{workspace}/automodel/healthz" in paths
1 change: 0 additions & 1 deletion plugins/nemo-automodel/tests/test_contributor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ def test_contributor_mounts_job_collection() -> None:
app.include_router(spec.router, prefix=spec.prefix)

paths = _route_paths(app)
assert "/v2/workspaces/{workspace}/automodel/healthz" in paths
assert "/v2/workspaces/{workspace}/automodel/jobs" in paths


Expand Down
Loading