Skip to main content

plugin-function

Creates in-process NemoFunction surfaces for NeMo Platform plugins. Use when adding a function, declaring spec_schema, mounting function routes with add_function_routes, understanding the two CLI verbs (run / submit), or streaming NDJSON frames. Trigger keywords - function, NemoFunction, spec_schema, add_function_routes, nemo_platform_plugin.functions, two verbs, run, submit, streaming, NDJSON, FunctionContext.

Informações da origem

Repositório
NVIDIA-NeMo/nemo-platform
Última atividade na origem
14 de agosto de 2026 às 17:32
Idioma detectado do SKILL.md
inglês
Estrelas
79
Forks
24

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
name
plugin-function
description
Creates in-process NemoFunction surfaces for NeMo Platform plugins. Use when adding a function, declaring spec_schema, mounting function routes with add_function_routes, understanding the two CLI verbs (run / submit), or streaming NDJSON frames. Trigger keywords - function, NemoFunction, spec_schema, add_function_routes, nemo_platform_plugin.functions, two verbs, run, submit, streaming, NDJSON, FunctionContext.
# Plugin Functions (NemoFunction) A `NemoFunction` is the third primitive on a plugin, alongside `NemoResource` and `NemoJob`. It's an in-process request handler — no scheduler, no backend dispatch — that the platform exposes as both a CLI subcommand and an HTTP route automatically. ```text nemo <plugin> <fn> run [--spec '{...}' | --spec-file FILE] [--workspace W] [<spec-flag>...] nemo <plugin> <fn> submit [--spec '{...}' | --spec-file FILE] \ [--base-url URL | --cluster URL] \ [--workspace W] [--request-id ID] [<spec-flag>...] ``` `run` is local (in-process); `submit` POSTs to the plugin service's auto-derived route. Two verbs only — no `explain`. A function's only schema is `spec_schema`, and `--help` is the introspection surface. ## CLI introspection — auto-generated per-field flags Every scalar leaf in `spec_schema` becomes a Typer flag automatically. Nested submodels recurse with dotted paths (`--target.url`, `--target.timeout-seconds`). For a function with `spec_schema = GreetSpec(name: str)`: ```text $ nemo my-plugin greet run --help ... Function Spec: --name <NAME> Name to greet. Spec Source: --spec <SPEC> Spec as a JSON string. [default: {}] --spec-file <SPEC_FILE> Path to a YAML or JSON spec file (used as base; per-flag values override). --workspace <WORKSPACE> Workspace identity passed to the function as ctx.workspace. [default: default] ... Function Spec flags are generated from the GreetSpec Pydantic schema. Precedence: --spec-file (base) → --spec JSON (overlay) → per-flag values (top). ``` Precedence at runtime is `--spec-file` (base) → `--spec` JSON (overlay) → per-field flag (top), then validation against `spec_schema` happens once on the merged value. Lists, dicts, discriminated unions, and other types the walker can't render fall through to `--spec` / `--spec-file`. The plumbing lives in `nemo_platform_plugin._spec_flags` and is shared with the jobs CLI; if a leaf is missing from `--help`, check that the field type is `str` / `int` / `float` / `bool` (or `Optional` of one of those) and that its name doesn't collide with a static verb flag. ## Class Signature ```python from typing import ClassVar from nemo_platform_plugin.function import NemoFunction from pydantic import BaseModel class GreetSpec(BaseModel): name: str class GreetResponse(BaseModel): message: str class GreetFunction(NemoFunction[GreetSpec]): name: ClassVar[str] = "greet" description: ClassVar[str] = "Say hello to a name." spec_schema: ClassVar[type[BaseModel]] = GreetSpec async def run(self, spec: GreetSpec) -> GreetResponse: return GreetResponse(message=f"Hello, {spec.name}!") ``` Required: `name`, `spec_schema`, `async def run()`. Optional: `description`, `endpoint`. ## Method colours — `run` is **always** `async def` `NemoFunction` is single-coloured. `run` is the request handler, so it must be `async def` to avoid blocking the FastAPI event loop while a stream is open. The base class enforces this at class-definition time: ```python class BadFunction(NemoFunction[GreetSpec]): name = "bad" spec_schema = GreetSpec def run(self, spec): # raises TypeError immediately at class definition return {} ``` Both shapes are accepted: | Shape | What it returns | Wire content type | |---|---|---| | `async def run(self, spec)` | A value (typically a Pydantic model) | `application/json` | | `async def run(self, spec): yield ...` | An `AsyncIterator` of frames | `application/x-ndjson` | Pure sync work goes through `await asyncio.to_thread(...)`: ```python async def run(self, spec: GreetSpec) -> GreetResponse: rendered = await asyncio.to_thread(_blocking_template_render, spec.name) return GreetResponse(message=rendered) ``` There is no `streaming: ClassVar[bool]` toggle — the route adapter detects the iterator return at request time. ## Streaming Yield Pydantic models from an `async def` body: ```python from collections.abc import AsyncIterator from nemo_platform_plugin.functions.frames import Done, Heartbeat from pydantic import BaseModel class TickSpec(BaseModel): upto: int class Tick(BaseModel): kind: str = "tick" n: int class CountFunction(NemoFunction[TickSpec]): name = "count" spec_schema = TickSpec async def run(self, spec: TickSpec) -> AsyncIterator[BaseModel]: for i in range(spec.upto): yield Tick(n=i) yield Done() ``` Each yielded `BaseModel` becomes one NDJSON line (`model_dump_json() + "\n"`) on the wire. The route adapter wraps the iterator with a heartbeat injector that emits a `Heartbeat()` frame every 5 seconds of idle time so proxies don't time the connection out. Plugin authors don't need to emit heartbeats themselves — the framework adds them. Convention frames live in `nemo_platform_plugin.functions.frames`: | Class | `kind` | When to emit | |---|---|---| | `Heartbeat` | `"heartbeat"` | Framework injects on idle; plugins rarely emit explicitly | | `Done` | `"done"` | Last frame of a successful stream | | `Error` | `"error"` | Last frame of a failed stream | The discriminator is the `kind` field. Use it in your own frames so consumers can branch with `jq -r 'select(.kind == "tick") | .n'` etc. ## Entry-Point Key Format Dot-separated: `"<plugin-name>.<function-name>"`. ```toml [project.entry-points."nemo.functions"] "my-plugin.greet" = "nemo_my_plugin.functions.greet:GreetFunction" ``` `NemoFunction.name` is the **suffix** after the dot. For `"my-plugin.greet"` the class sets `name = "greet"`. ## Mounting function routes — `add_function_routes(fn_cls)` This is the one-liner that mounts the function as a `POST` route on your plugin service: ```python from fastapi import FastAPI from nemo_platform_plugin.authz import AuthzScope from nemo_platform_plugin.functions.routes import add_function_routes from .functions.greet import GreetFunction app = FastAPI() router = add_function_routes( GreetFunction, authz=AuthzScope("my-plugin"), permission_description="Invoke the greet function", ) app.include_router( router, prefix="/apis/my-plugin/v2/workspaces/{workspace}", ) # POST /apis/my-plugin/v2/workspaces/{workspace}/greet ``` `authz=` is **required** — pass your plugin's `AuthzScope` or the route is left unruled, and under the default `on_invalid_plugin=hard_fail` the auth service refuses to build the OPA bundle (the platform 502s) rather than silently fencing the route. See the `plugin-authz` skill for the full path-rule model. `add_function_routes`: - Validates the body against `spec_schema` (422 on bad input). - Builds a `FunctionContext` from the workspace path parameter and the optional `X-Request-ID` header. - Resolves keyword-only DI parameters on `run` by name — `ctx`, `async_sdk` (sync `sdk` injection lands in a follow-up). - Awaits `run(spec, **resolved)` and serialises the result; returns `application/x-ndjson` with heartbeat injection if the return is an async iterator. - Stamps the route's authz from `authz=` — a `PRINCIPAL` `@path_rule` carrying a write-action invoke permission (`<namespace>.<function-name>`, e.g. `my-plugin.greet`) plus `@AuthzScope.write`. `permission_description` overrides that permission's description (it defaults to the function's `description`); passing it without `authz=` is a `ValueError`. Optional kwarg `heartbeat_interval_seconds` overrides the 5-second default — useful in tests. ## Endpoint override The default route path is `/{name}` mounted under the workspace prefix — i.e. `/apis/<plugin>/v2/workspaces/{workspace}/<name>`. Override the trailing segment per-class via `endpoint`: ```python class GreetFunction(NemoFunction[GreetSpec]): name = "greet" spec_schema = GreetSpec # Mount at /apis/.../v2/workspaces/{workspace}/greet/v1 to keep # an existing client URL working. endpoint: ClassVar[str | None] = "/{name}/v1" ``` Only `{name}` is substituted; the workspace placeholder stays as a live FastAPI route parameter. The override doesn't let a function relocate itself outside its plugin's URL namespace — that's intentional. ## DI by signature `run` accepts framework-managed dependencies as keyword-only parameters. The route adapter and the local CLI both resolve them by parameter name: ```python from nemo_platform_plugin.function import NemoFunction from nemo_platform_plugin.function_context import FunctionContext class WhoamiFunction(NemoFunction[GreetSpec]): name = "whoami" spec_schema = GreetSpec async def run( self, spec: GreetSpec, *, ctx: FunctionContext, async_sdk: object | None = None, ) -> dict: return { "workspace": ctx.workspace, "request_id": ctx.request_id, "sdk_present": async_sdk is not None, } ``` | Parameter | Source | Notes | |---|---|---| | `ctx: FunctionContext` | route adapter / CLI | `workspace` from URL or `--workspace`, `request_id` from `X-Request-ID` | | `async_sdk` | route adapter only (today) | Plugin services need `app.dependency_overrides[get_sdk_client]` to inject a real handle | | `sdk` | declared but currently bound to `None` | Sync placeholder lands with the SDK-builder follow-up | Parameters not declared on `run` aren't injected — there's no implicit context. Keep `run` signatures narrow. ## Programmatic invocation ```python import asyncio from nemo_platform_plugin.discovery import discover_functions from nemo_platform_plugin.function_context import FunctionContext fn_cls = discover_functions()["my-plugin.greet"] spec = fn_cls.spec_schema.model_validate({"name": "Ada"}) result = asyncio.run(fn_cls().run(spec, ctx=FunctionContext(workspace="default"))) ``` `discover_functions()` returns the typed mapping. There is no `FunctionScheduler` — the function is the runtime; instantiate and `await`. ## Testing Unit-test functions directly — no FastAPI, no platform required: ```python import pytest @pytest.mark.asyncio async def test_greet_returns_message(): fn = GreetFunction() result = await fn.run(GreetSpec(name="Ada")) assert result.message == "Hello, Ada!" ``` Route-test with `TestClient` when you want to exercise validation, DI, and streaming: ```python from fastapi import FastAPI from fastapi.testclient import TestClient from nemo_platform_plugin.authz import AuthzScope from nemo_platform_plugin.functions.routes import add_function_routes def test_greet_route_returns_json(): app = FastAPI() app.include_router( add_function_routes(GreetFunction, authz=AuthzScope("my-plugin")), prefix="/apis/my-plugin/v2/workspaces/{workspace}", ) client = TestClient(app) response = client.post( "/apis/my-plugin/v2/workspaces/default/greet", json={"name": "Ada"}, ) assert response.status_code == 200 assert response.json() == {"message": "Hello, Ada!"} ``` For streaming functions, set a low heartbeat interval so tests aren't flaky: ```python add_function_routes(CountFunction, heartbeat_interval_seconds=0, authz=AuthzScope("my-plugin")) ``` ## Gotchas - **`run` must be `async def`**: The class enforces this at definition time. Sync work uses `await asyncio.to_thread(...)`. - **`name` is the suffix only**: For entry-point key `"my-plugin.greet"`, `NemoFunction.name = "greet"`. The class declares the suffix; the dot-prefix lives in `pyproject.toml`. - **`spec_schema` is required**: `add_function_routes` raises `TypeError` if it's missing. Declare it before mounting. - **`authz=` is required**: pass your plugin's `AuthzScope` to `add_function_routes`, or the route is unruled and the OPA bundle build fails closed under `hard_fail`. See the `plugin-authz` skill. - **Streaming is detected from the return type, not declared**: An `async def` with `yield` is an async generator — the route emits NDJSON. An `async def` with `return` is a coroutine — the route emits JSON. Don't add a class-level streaming flag. - **`endpoint` is the trailing segment**: Setting `endpoint = "/{name}/v1"` mounts under the workspace prefix; you can't relocate the function to a different plugin's URL namespace. - **Reserved CLI flag**: `submit --workspace` controls the URL path segment — don't put a `workspace` field in your spec model with conflicting semantics. For the architectural framing alongside `NemoResource` and `NemoJob`, see the shared resources/jobs/functions design note.
Ver no GitHub