Published

Crear un model adapter válido en una notebook

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

Crear un model adapter válido en una notebook

Guía end-to-end para construir un model adapter en una notebook de Node y registrarlo desde Training Studio. Cubre los dos tipos: External (modelo alojado tras un endpoint HTTP) y Upload (artefacto entrenado y serializado).

El adapter se construye en la notebook; se importa + deploya desde
Studio. La notebook nunca registra un external desde una celda — su trabajo
termina al construir el adapter y validarlo.


0. Prerequisito — el SDK ya está instalado (no hay pip install)

node-sdk viene preinstalado en el kernel: en el env base de la imagen del Hub y, en los environments por-notebook, inyectado vía el manifest (node-sdk @ file:///workspace/.node-sdk-source). No instalas nada.

Verifícalo en una celda:

from node.adapters import BaseAdapter, ExternalAdapter, ExternalConnection, validate_candidate
print("✅ node-sdk listo")

Si falla con ImportError, el kernel tiene una versión vieja del SDK:

import node, node.adapters, pathlib
print("instalado:", node.adapters.__file__)
print("tiene ExternalAdapter:", hasattr(node.adapters, "ExternalAdapter"))
src = pathlib.Path("/workspace/.node-sdk-source/node/adapters/base.py")
print("source tiene ExternalAdapter:", src.exists() and "class ExternalAdapter" in src.read_text())
  • instalado=False / source=True → reinstala en un env escribible (no el base /srv/venv, que es read-only) y reinicia el kernel.
  • source=False → la imagen del Hub está vieja → rebuild + redeploy del Hub.

1. El flujo, de un vistazo

  1. Side-panel ML → Models → Add ▾ → Create new model → nombre.
  2. En el paso alias: elige kind (Upload / External) → Create. Esto materializa <alias>_adapter.py con el template del kind elegido.
  3. Rellena el .py (los TODO).
  4. Pre-flight: corre validate_candidate(...) en una celda → READY.
  5. Import desde Training Studio → registra + deploya.

El model creado desde el panel queda marcado created_from='notebook', así que aparece en el panel de la notebook (los creados en Studio no — para eso está "Import existing model").


2. ¿Qué hace VÁLIDO a un adapter?

Esto es exactamente lo que comprueba validate_candidate(module, require_entrypoint=True) — el mismo gate que corre Studio al importar.

Común a ambos kinds

ReglaCódigo de fallo si no se cumple
Exactamente una clase adapter en el archivoAMBIGUOUS / NO_ADAPTER
Subclasea un solo kind (no mezcla BaseAdapter + ExternalAdapter)MIXED_KIND
predict(self, input) acepta ≥1 argumento además de selfPREDICT_ARITY
Sin métodos abstractos sin implementarABSTRACT

External (ExternalAdapter)

ReglaCódigo
extract_config() está overrideado y es @classmethodCONFIG_NOT_OVERRIDDEN / CONFIG_WRONG_TYPE
NO define serialize / deserialize / load (no hay artefacto)FORBIDDEN_METHOD
schema() se hereda (no hace falta redefinirlo)

Upload (BaseAdapter)

ReglaCódigo
load() está overrideado y es @classmethodLOAD_NOT_OVERRIDDEN / LOAD_WRONG_TYPE
deserialize() es @classmethodDESERIALIZE_WRONG_TYPE
serialize() / schema() / predict() implementadosABSTRACT

3. External adapter — modelo alojado tras un endpoint HTTP

Ejemplo testeable contra httpbin.org/post (echo público, pasa el guard SSRF), para probar toda la tubería sin un modelo real. Reemplaza la URL/auth/parseo por los de tu endpoint (Azure ML, SageMaker, Vertex AI, OpenAI, …).

from __future__ import annotations
from typing import Any
from node.adapters.base import ExternalAdapter, ExternalConnection


class EchoAdapter(ExternalAdapter):
    adapter_class = "EchoAdapter"
    framework_hint = "external"

    @classmethod
    def extract_config(cls) -> ExternalConnection:
        # Sugerencia: en Studio confirmas la URL/creds AUTORITATIVAS.
        return ExternalConnection(
            url="https://httpbin.org/post",
            method="POST",
            headers={"Content-Type": "application/json"},
            auth_type="none",            # "none" | "bearer" | "basic"
            # auth_token="...",          # sugerencia; el token real se teclea en Studio
        )

    def predict(self, input: Any) -> Any:
        # self.http es el cliente guardado que inyecta la plataforma en serve time.
        # self.connection es una proyección redactada (url/method/headers/auth_type;
        # SIN token — la plataforma adjunta la auth en el hop saliente).
        response = self.http.post(
            self.connection.url,
            json={"inputs": input},
            timeout=30.0,
        )
        response.raise_for_status()
        return response.json()["json"]["inputs"]   # httpbin devuelve el body bajo "json"

Pre-flight (mismo gate que Studio)

import importlib, echo_adapter
importlib.reload(echo_adapter)
from node.adapters import validate_candidate

r = validate_candidate(echo_adapter, require_entrypoint=True)
print(r)   # CandidacyResult(ok=True, kind='external', class_name='EchoAdapter', code='READY', ...)
assert r.ok, f"❌ {r.code}: {r.detail}"

Dry-run opcional de predict() (sin desplegar)

self.http solo se inyecta en serve time; fuera de ahí lanza a propósito. Para probar la forma del request/response, inyecta un cliente fake:

class FakeResp:
    status_code = 200
    def __init__(self, p): self._p = p
    def json(self): return self._p
    @property
    def text(self): return str(self._p)
    @property
    def headers(self): return {}
    def raise_for_status(self): return self

class FakeHttp:
    def post(self, url, *, json=None, headers=None, timeout=None):
        return FakeResp({"json": json})            # imita a httpbin
    def get(self, url, **kw): return FakeResp({})
    def request(self, method, url, **kw): return FakeResp({"json": kw.get("json")})

importlib.reload(echo_adapter)
adapter = echo_adapter.EchoAdapter.configure(
    connection=echo_adapter.EchoAdapter.extract_config(),
    http=FakeHttp(),
)
print(adapter.predict({"feature": 1.0}))           # → [1.0]

Modelo de seguridad (importante)

  • El token nunca entra al runner: lo adjunta Node server-side en el chokepoint de egress. Tu predict() jamás sostiene la credencial.
  • La URL/creds autoritativas las confirma el operador en Studio; lo que pongas en extract_config() es una sugerencia (la URL no auto-rellena el campo, y auth_token/headers se proyectan fuera y nunca se persisten desde el .py).
  • La red solo se alcanza vía self.http (SSRF guard + circuit breaker + timeout).

4. Upload adapter — artefacto entrenado y serializado

El template subclasea BaseAdapter y su load() lee un <alias>_model.pkl hermano. El contrato: una celda de entrenamiento debe guardar ese pkl.

from __future__ import annotations
from pathlib import Path
from typing import Any
import pickle
from node.adapters.base import BaseAdapter


class ChurnAdapter(BaseAdapter):
    adapter_class = "ChurnAdapter"
    framework_hint = "sklearn"          # "sklearn" | "pytorch" | "xgboost" | "custom"

    def __init__(self, model: Any):
        self.model = model

    @classmethod
    def load(cls) -> "ChurnAdapter":
        model_path = Path(__file__).parent / "churn_model.pkl"
        if not model_path.exists():
            raise FileNotFoundError(f"Guarda tu modelo con joblib.dump(model, '{model_path.name}')")
        with model_path.open("rb") as f:
            return cls(pickle.load(f))

    def serialize(self) -> bytes:
        return pickle.dumps(self.model)

    @classmethod
    def deserialize(cls, data: bytes) -> "ChurnAdapter":
        return cls(pickle.loads(data))

    def schema(self) -> dict[str, Any]:
        return {
            "input":  {"type": "object", "properties": {"feature": {"type": "number"}}},
            "output": {"type": "number"},
        }

    def predict(self, input: Any) -> Any:
        return self.model.predict(input)

En tu celda de entrenamiento, al final:

import joblib
joblib.dump(trained_model, "churn_model.pkl")   # el contrato que lee load()

Pre-flight

import importlib, churn_adapter
importlib.reload(churn_adapter)
from node.adapters import validate_candidate

r = validate_candidate(churn_adapter, require_entrypoint=True)
assert r.ok, f"❌ {r.code}: {r.detail}"           # exige load() overrideado como @classmethod
print("✅", r.code)

Upload tiene además un camino "publish desde celda" (el snippet del paso
Publish del panel). External NO — siempre se importa desde Studio.


5. Importar desde Training Studio

Con el pre-flight en READY:

  1. Studio → Import from a notebook → el picker detecta <alias>_adapter.py y su kind.
  2. External: confirmas la URL/creds autoritativas (la sugerencia de extract_config() aparece pero no auto-rellena) → registra source_type=external / external_mode=runner.
  3. Upload: Studio llama load() headless (lee el pkl) → sube el artefacto.
  4. Deploy.

6. Troubleshooting — códigos de validate_candidate

CódigoQué significaArreglo
NO_ADAPTERNo hay subclase de ModelAdapterComprueba el class X(BaseAdapter/ExternalAdapter)
AMBIGUOUSHay >1 clase adapter en el archivoDeja exactamente una
MIXED_KINDSubclasea ambos kindsHereda de un solo base
KIND_MISMATCHcls.kind no concuerdaNo toques kind (lo fija el base)
ABSTRACTFaltan métodos abstractosImplementa predict/schema (+serialize/deserialize en upload)
PREDICT_ARITYpredict no acepta inputFirma predict(self, input)
FORBIDDEN_METHODExternal define serialize/deserialize/loadBórralos (external no tiene artefacto)
CONFIG_NOT_OVERRIDDEN / CONFIG_WRONG_TYPEextract_config falta o no es @classmethodOverride como @classmethod que devuelve ExternalConnection
LOAD_NOT_OVERRIDDEN / LOAD_WRONG_TYPEload falta o no es @classmethodOverride load() como @classmethod que lee el pkl
DESERIALIZE_WRONG_TYPEdeserialize no es @classmethodDecóralo con @classmethod
READY✅ válidoImporta desde Studio

Referencias en el código

  • Contrato SDK: services/jupyterhub/node-sdk/node/adapters/base.py
  • Validador: services/jupyterhub/node-sdk/node/adapters/contract.py
  • Templates que genera el panel: components/workspace/tabs/jupyter/side-panel/AliasSidebar.tsx
  • Import headless (runner): services/ml-runner/app/routers/import_headless.py