Skip to main content

plugin-function

Creates NemoFunction HTTP surfaces for NeMo Helix plugins. Use when adding a function, declaring spec_schema, mounting function routes with add_function_routes, understanding the generated flat CLI command, or streaming NDJSON frames. Trigger keywords - function, NemoFunction, spec_schema, add_function_routes, nemo_helix_plugin.functions, streaming, NDJSON, FunctionContext.

Datos de origen

Repositorio
NVIDIA-NeMo/nemo-helix
Última actividad en el origen
15 de octubre de 2026 a las 12:03
Idioma detectado de SKILL.md
inglés
Estrellas
92
Forks
36

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
name
plugin-function
description
Creates NemoFunction HTTP surfaces for NeMo Helix plugins. Use when adding a function, declaring spec_schema, mounting function routes with add_function_routes, understanding the generated flat CLI command, or streaming NDJSON frames. Trigger keywords - function, NemoFunction, spec_schema, add_function_routes, nemo_helix_plugin.functions, 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> [--spec '{...}' | --spec-file FILE] [--workspace W] [--request-id ID] [<spec-flag>...] ``` The command POSTs to the plugin service's auto-derived route on the platform selected by the global `nemo --base-url` / `nemo --context` flags and the active CLI context. There is 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 --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). Submission: -w, --workspace <WORKSPACE> Workspace path segment used in the submit URL. --request-id <REQUEST_ID> Set the X-Request-ID header (echoed back in ctx.request_id). ... 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_helix_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_helix_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[GreetSpec]] = GreetSpec async def run(self, spec: GreetSpec) -> GreetResponse: return GreetResponse(message=f"Hello, {spec.name}!") ``` Required: `name`, `spec_schema`, and `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_helix_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_helix_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_helix_plugin.authz import AuthzScope from nemo_helix_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`, `sdk` (request-scoped `NemoClient`), `async_sdk` (request-scoped `AsyncNemoClient`). - 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 resolves them by parameter name: ```python from nemo_helix_plugin.client.client import AsyncNemoClient from nemo_helix_plugin.function import NemoFunction from nemo_helix_plugin.function_context import FunctionContext class WhoamiFunction(NemoFunction[GreetSpec]): name = "whoami" spec_schema = GreetSpec async def run( self, spec: GreetSpec, *, ctx: FunctionContext, async_sdk: AsyncNemoClient | None = None, ) -> dict: return { "workspace": ctx.workspace, "request_id": ctx.request_id, "client_present": async_sdk is not None, } ``` | Parameter | Source | Notes | |---|---|---| | `ctx: FunctionContext` | route adapter | `workspace` from the URL (set by the CLI's `--workspace`), `request_id` from `X-Request-ID` | | `async_sdk: AsyncNemoClient` | route adapter (`Depends(get_nemo_client)`) | Request-scoped typed client carrying the caller's principal; derive service clients with `<Client>.from_client(async_sdk)` | | `sdk: NemoClient` | route adapter (`Depends(get_sync_nemo_client)`) | Sync counterpart for sync-only libraries | Parameters not declared on `run` aren't injected — there's no implicit context. Keep `run` signatures narrow. ## Programmatic invocation ```python import asyncio from nemo_helix_plugin.discovery import discover_functions from nemo_helix_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_helix_plugin.authz import AuthzScope from nemo_helix_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**: `--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 en GitHub