# Contributing to OpenNVR AI Adapter Thanks for being here. This repo is two things in one: the `opennvr-adapter-sdk` PyPI package (the boilerplate-free way to ship a contract-compliant adapter) and a reference monolith server that bundles several adapters in one process. There's a contribution path for each, and the recommended path depends on what you're trying to do. ## Which path are you on? If you're shipping a new adapter, use **Path A — the SDK plus a standalone container.** Your adapter becomes its own service with its own `pyproject.toml`, declared permissions, and lifecycle. Nothing in this repo needs to change, and the adapter ships under whatever licence you choose. The seven shipped adapters in `adapters//` are the reference implementations — find the one with the closest body shape and copy the structure. Bug fixes to those services also live on this path. If you're extending the bundled reference monolith — `app/adapters//` — use **Path B**, the `BaseAdapter` plugin pattern. This path stays supported for contributors keeping the monolith useful, but new adapters should target Path A: a standalone container is contract-compliant out of the box, ships independently, and survives any future change to the monolith's internals. If you're improving the SDK itself, work in [`opennvr_adapter_sdk/`](opennvr_adapter_sdk/) and read its [README](opennvr_adapter_sdk/README.md) for the surface and conventions. ## Development setup ```bash git clone https://github.com/open-nvr/ai-adapter.git cd ai-adapter uv venv && source .venv/bin/activate # Windows: .venv\Scripts\activate uv sync --extra all --extra cpu --extra dev # Download model weights for the adapters you'll touch uv run python download_models.py # Run the legacy monolith server (still the simplest way to dogfood everything) uv run uvicorn app.main:app --reload --port 9100 # Run tests uv run pytest tests/ -v ``` Per-adapter services live in `adapters//` and are their own deployable units. Each one has its own `pyproject.toml`, `service.py`, `main.py`, and tests. They're built and published as separate GHCR images by `.github/workflows/publish-images.yml`. ## Anti-bloat rules — read this first These apply to **both paths**. They exist because the project becomes unusable for everyone if every adapter drags in PyTorch, transformers, and onnxruntime by default. ### Rule 1: Heavy ML libraries go in optional dependency groups Never add an ML library to `[project.dependencies]`. That section is shared core — keep it small. ```toml # pyproject.toml # WRONG — forces ~500 MB on every deployment [project.dependencies] "my-ml-library>=1.0.0" # RIGHT — only installed when the user opts in [project.optional-dependencies] my_adapter = ["my-ml-library>=1.0.0"] # Also add to the "all" group so `uv sync --extra all` still works all = ["opennvr-ai-adapter[yolo,yolo11,face,blip,huggingface,my_adapter]"] ``` For Path A (standalone container), the equivalent is your per-adapter `pyproject.toml` — keep it tight, list only what the adapter actually needs. The Dockerfile is built from this so every megabyte counts. ### Rule 2: Import heavy libraries inside `load()` / `load_model()`, not at the top In Path A, `AdapterService.load()` is the lifecycle hook the SDK guarantees is called once before the service goes ready. In Path B, `BaseAdapter.load_model()` is the equivalent. Either way, defer heavy imports until you actually need them. ```python # WRONG — torch loads even if this adapter is never used import torch from transformers import AutoModel class MyService(AdapterService): def load(self): self._model = AutoModel.from_pretrained("...") # RIGHT — torch loads only when the adapter is actually instantiated class MyService(AdapterService): def load(self): import torch from transformers import AutoModel self._model = AutoModel.from_pretrained("...") ``` ### Rule 3: What happens when a dep isn't installed Path A (standalone): your container's `pip install` lists everything; the adapter either runs or fails to start. No surprises. Path B (monolith): `PluginManager` catches the `ImportError` at discovery time and logs `Skipping 'app.adapters.vision.my_adapter': optional dependency 'my_lib' not installed. Install with: uv sync --extra my_adapter`. All other adapters load fine; yours just doesn't appear in `/capabilities`. No crash, no stack trace. ## Path A — SDK + standalone container The end state is a Docker image that runs a single `AdapterService` behind the FastAPI app `AdapterApp` builds for you. The SDK README walks the minimum viable example in detail; the short version: ```python # my_adapter/main.py from datetime import datetime, timezone from opennvr_adapter_sdk import ( AdapterApp, AdapterService, BodyShape, BODY_BYTES_KEY, HardwareEvaluationResponse, HardwareVerdict, InferResponse, ModelInfo, ) class MyService(AdapterService): def load(self): import onnxruntime as ort self._model = ort.InferenceSession("/weights/my-model.onnx") def is_ready(self) -> bool: return self._model is not None def fingerprint(self) -> str | None: return "sha256:..." # sha256 of the weights file def model_info(self) -> ModelInfo: return ModelInfo( name="my-model", version="1.0.0", framework="onnx", fingerprint=self.fingerprint(), ) def hardware_evaluation(self) -> HardwareEvaluationResponse: return HardwareEvaluationResponse( verdict=HardwareVerdict.OK, reasoning="ok", checked_at=datetime.now(timezone.utc), details={}, ) def infer(self, payload) -> InferResponse: frame_bytes = payload[BODY_BYTES_KEY] # ... run your model ... return InferResponse( model_name="my-model", model_version="1.0.0", inference_ms=42, result={"detections": [{"label": "fire", "confidence": 0.95}]}, ) app = AdapterApp( service=MyService(), name="my-adapter", version="1.0.0", vendor="me", license="MIT", tasks_advertised=["fire_detection"], body_shape=BodyShape.IMAGE, ).fastapi_app ``` ```bash OPENNVR_ADAPTER_TOKEN=dev-token \ uvicorn my_adapter.main:app --host 0.0.0.0 --port 9001 ``` Then verify your adapter is contract-compliant: ```bash python -m conformance http://localhost:9001 --token dev-token ``` The conformance suite checks every required endpoint, the `/health` state machine, the failure envelope shape, and the multipart/JSON body parser. If your service passes conformance, KAI-C will register it. **Reference implementations** to copy-from in this repo: - `adapters/yolov8/` — `BodyShape.IMAGE`, WebSocket streaming - `adapters/whisper/` — `BodyShape.AUDIO`, audio decode pipeline - `adapters/piper/` — `BodyShape.TEXT`, custom `/voices` route - `adapters/insightface/` — `BodyShape.IMAGE`, face DB enrollment endpoints - `adapters/blip/` — `BodyShape.IMAGE`, transformer model - `adapters/fast_plate_ocr/` — `BodyShape.IMAGE`, OCR over a pre-cropped plate - `adapters/bytetrack/` — `BodyShape.TEXT`, stateful post-processor over an upstream detector Full SDK reference: [`opennvr_adapter_sdk/README.md`](opennvr_adapter_sdk/README.md). ## Path B — Add to the reference monolith The monolith uses a plugin pattern that predates the contract. It's still supported because it's simpler for contributors who just want to extend the bundled server without standing up a separate container. ### Option B1 — Adapter only (raw output) The simplest shape: your adapter's raw output goes straight to the client. No Pydantic validation. ```python # app/adapters/vision/my_adapter.py from app.adapters.base import BaseAdapter class MyAdapter(BaseAdapter): name = "my_adapter" # Unique; used in TASK_ADAPTER_MAP routing type = "vision" # "vision" or "llm" def __init__(self, config=None): self.config = config or {} self.model = None # Must start as None for lazy loading def load_model(self): # Import heavy libraries HERE, not at the top of the file. import my_ml_library self.model = my_ml_library.load(self.config.get("weights_path")) def infer_local(self, input_data): from app.utils.image_utils import load_image_from_uri img = load_image_from_uri(input_data["frame"]["uri"]) return self.model.predict(img) ``` ```toml # pyproject.toml — add the optional dep group [project.optional-dependencies] my_adapter = ["my-ml-library>=1.0.0"] all = ["opennvr-ai-adapter[yolo,yolo11,face,blip,huggingface,my_adapter]"] ``` ```python # app/config/config.py — register routing TASK_ADAPTER_MAP["my_task"] = "my_adapter" CONFIG["adapters"]["my_adapter"] = {"enabled": True, "weights_path": "my_model.bin"} ``` ```python # download_models.py — only if your model needs a local weights file MODEL_REGISTRY["my_adapter"] = [ { "filename": "my_model.bin", "url": "https://github.com/me/my-models/releases/download/v1/my_model.bin", "size_hint": "~50 MB", } ] ``` Restart the server. `PluginManager` auto-discovers your class. Done. ### Option B2 — Adapter + Pydantic-validated task Same as B1 plus a task pipeline that validates the adapter output against a Pydantic schema before returning it. ```python # app/schemas/responses.py class MyResponse(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) label: str confidence: float = Field(ge=0.0, le=1.0) bbox: list[int] ``` ```python # app/pipelines/my_task/task.py from app.interfaces.task import BaseTask from app.schemas.responses import MyResponse class MyTask(BaseTask): name = "my_task" # Must match the key in TASK_ADAPTER_MAP def process(self, image, adapter): raw = adapter.predict(image) return MyResponse(**raw) ``` Full Path B tutorial with all the details: [`docs/PLUGIN_DEVELOPMENT.md`](docs/PLUGIN_DEVELOPMENT.md). ## How auto-discovery works (Path B only) At startup `PluginManager.discover_plugins()`: 1. Walks every non-private `.py` file under `app/adapters/` and `app/pipelines/`. 2. Attempts `importlib.import_module(...)` on each. - `ImportError` → logs a helpful skip message (missing optional dep — expected). - Other error → WARNING (real bug in your code). - Success → registers any `BaseAdapter` / `BaseTask` subclasses by their `name`. 3. Logs a summary: `Discovered 3 adapters: [insightface_adapter, yolov8_adapter, yolov11_adapter]`. No imports in `main.py`. No registration calls. Just create the file and restart. (Path A doesn't need this — your service is the only class in the container.) ## Installation profiles (monolith) Per-profile dependencies and sizes are documented in the main [README](README.md#install-profiles). The relevant addition for contributors is the `--extra dev` group, which adds the test suite and tooling (around 4 GB total with `--extra all --extra cpu --extra dev`). ## Testing ```bash # Monolith suite uv run pytest tests/ -v # Per-adapter suites uv run pytest adapters/yolov8/tests/ -v uv run pytest adapters/whisper/tests/ -v # ...etc # With coverage uv run pytest tests/ --cov=app --cov-report=term-missing # Conformance check against a running adapter python -m conformance http://localhost:9001 --token dev-token ``` When adding a new adapter, please add tests in either `tests/` (Path B) or `adapters//tests/` (Path A). ## Pre-PR checklist ### For both paths - [ ] Heavy ML libraries imported inside `load()` / `load_model()`, not module top. - [ ] Optional dep group declared (or per-adapter `pyproject.toml` updated). - [ ] Tests added for the new behaviour. - [ ] Tested with `curl POST /infer` locally. - [ ] All tests pass: `uv run pytest -v`. ### Path A additions - [ ] Adapter passes the conformance suite (`python -m conformance ...`). - [ ] Per-adapter `pyproject.toml` lists only what the adapter actually needs. - [ ] Per-adapter `Dockerfile` builds and produces a runnable image. ### Path B additions - [ ] `BaseAdapter` subclass has `name` and `type` class attributes. - [ ] Model loads in `load_model()`, not `__init__()`. - [ ] Config updated: `TASK_ADAPTER_MAP` + `CONFIG["adapters"]`. - [ ] If local weights needed: `MODEL_REGISTRY` entry added in `download_models.py`. - [ ] If returning structured output: Pydantic response model added to `app/schemas/responses.py`. - [ ] Response JSON example added to `docs/API_REFERENCE.md`. ## Commit messages [Conventional Commits](https://www.conventionalcommits.org/). Common scopes: `sdk`, `adapters/`, `app`, `conformance`, `docs`. ```text feat(adapters/yolov8): support WebSocket streaming protocol fix(sdk): correct BodyShape.AUDIO multipart field name docs(contributing): clarify Path A vs Path B ``` ## Code of conduct We want this to stay a project people enjoy contributing to. The expectation is welcoming language and respectful disagreement; harassment, doxxing, and inflammatory off-topic posting are not part of that. Report violations to **contact@opennvr.org** — reports are confidential. ## License By contributing, you agree your contributions are licensed under: - **AGPL v3** for code in `app/`, `adapters//`, and the rest of the reference server. - **Apache-2.0** for code in `opennvr_adapter_sdk/` — the SDK is intentionally permissive so adapter authors can publish under any compatible license, including proprietary.