Source code for tlc_plugin_sdk.contract
# Copyright 2026 3LC Inc.
# SPDX-License-Identifier: Apache-2.0
"""The compute-service plugin contract — a behavior-only base class.
A plugin is a subclass of :class:`ComputePlugin`. The base declares the
*behavioral* surface the host (or an out-of-process worker) invokes; all
*metadata* (id, name, ui placement, gpu flag, socketio namespace, …) lives in
the plugin manifest — a standalone ``plugin.toml`` (bare, un-namespaced keys) or,
equivalently, a ``[tool.tlc-compute]`` table in the plugin's ``pyproject.toml`` —
the single source of truth. There is **no metadata on the class** and **no**
``register()`` call at import — the host discovers a plugin via its manifest
``entrypoint`` and hydrates the instance's display identity
(``id``/``name``/``icon``/``version``) from the card after construction.
Only ``get_ui_fragment`` is required (abstract); everything else is optional
behavior with a safe default, so a plugin implements just what it needs and the
host calls every hook directly against the inherited defaults.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from tlc_plugin_sdk.job_context import JobContext
[docs]
class ComputePlugin(ABC):
"""Behavior-only base class for a compute-service plugin (host or venv).
Subclass and implement at least :meth:`get_ui_fragment`. The optional hooks
below (``compute``, ``run_job``, lifecycle, routes) ship as safe defaults, so
the host can call any of them directly without probing for the method first.
Attributes:
id: Unique slug (e.g. ``run-insights``). Identity only, hydrated onto the
instance from the manifest by the host; the rest of the plugin's
metadata also comes from its manifest, never from the instance.
"""
id: str
[docs]
@abstractmethod
def get_ui_fragment(self) -> str:
"""Return a self-contained HTML+JS+CSS fragment for the plugin UI."""
...
# ── Optional behavior (safe defaults) ─────────────────────────────────────
[docs]
def compute(self, params: dict[str, Any]) -> dict[str, Any]:
"""Execute the plugin's synchronous ``GET /compute`` computation.
Override to expose a synchronous compute endpoint; the default returns an
error dict so a plugin that only serves a UI and/or jobs need not implement
it. Long-running work belongs in :meth:`run_job`, not here.
Returns:
A JSON-serializable dict. The default is
``{"error": f"{id} does not implement compute()"}``.
"""
plugin_id = getattr(self, "id", "?")
return {"error": f"{plugin_id} does not implement compute()"}
[docs]
def initialise_runtime(self) -> None:
"""Initialise the plugin's runtime resources (runners, stores, models).
Called once after the shared GPU queue is ready. Default is a no-op.
"""
[docs]
def shutdown_runtime(self) -> None:
"""Tear down the plugin's runtime resources.
Must be safe to call on a plugin that was never initialised. Default is a
no-op.
"""
[docs]
def run_job(self, ctx: JobContext) -> None:
"""Run a long-running job against a host-provided context.
The plugin reports progress/metrics and polls cancellation via ``ctx``;
the code runs in the plugin's worker and only ever touches ``ctx``.
Raises:
NotImplementedError: The default — a plugin that streams jobs must
override this.
"""
plugin_id = getattr(self, "id", "?")
msg = f"Plugin '{plugin_id}' does not implement run_job()"
raise NotImplementedError(msg)
# Job listing, busy checks, and cancellation deliberately do NOT live on the
# plugin: the host owns every job's lifecycle (it started the job via run_job),
# so it lists, gates, and cancels. A plugin only implements run_job.
[docs]
def get_route_handlers(self) -> list[Any]:
"""Return the plugin's custom routes as relative Litestar route handlers.
Each handler's path is **relative** to the plugin's mount point
``/api/plugins/{plugin_id}/`` (e.g. a ``@get("/models")`` handler serves
``GET /api/plugins/{plugin_id}/models``). The handlers are served by the
plugin's own Litestar app in its worker, reverse-proxied by the host (see
``tlc_plugin_sdk/asgi_app.py``); Litestar runs ``def`` handlers in a threadpool,
so a synchronous, blocking custom route does not block the event loop. The
reserved routes (``/run``, ``/health``, ``/ui``, ``/compute``, ``/jobs/*``,
and the host admin routes ``/provision`` ``/reload`` ``/venv`` ``/worker/stop``)
are host-owned — a plugin must not define them. Empty by default.
"""
return []