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
- Side-panel ML → Models → Add ▾ → Create new model → nombre.
- En el paso alias: elige kind (Upload / External) → Create.
Esto materializa
<alias>_adapter.pycon el template del kind elegido. - Rellena el
.py(losTODO). - Pre-flight: corre
validate_candidate(...)en una celda →READY. - 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
| Regla | Código de fallo si no se cumple |
|---|---|
| Exactamente una clase adapter en el archivo | AMBIGUOUS / NO_ADAPTER |
Subclasea un solo kind (no mezcla BaseAdapter + ExternalAdapter) | MIXED_KIND |
predict(self, input) acepta ≥1 argumento además de self | PREDICT_ARITY |
| Sin métodos abstractos sin implementar | ABSTRACT |
External (ExternalAdapter)
| Regla | Código |
|---|---|
extract_config() está overrideado y es @classmethod | CONFIG_NOT_OVERRIDDEN / CONFIG_WRONG_TYPE |
NO define serialize / deserialize / load (no hay artefacto) | FORBIDDEN_METHOD |
schema() se hereda (no hace falta redefinirlo) | — |
Upload (BaseAdapter)
| Regla | Código |
|---|---|
load() está overrideado y es @classmethod | LOAD_NOT_OVERRIDDEN / LOAD_WRONG_TYPE |
deserialize() es @classmethod | DESERIALIZE_WRONG_TYPE |
serialize() / schema() / predict() implementados | ABSTRACT |
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, yauth_token/headersse 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:
- Studio → Import from a notebook → el picker detecta
<alias>_adapter.pyy su kind. - External: confirmas la URL/creds autoritativas (la sugerencia de
extract_config()aparece pero no auto-rellena) → registrasource_type=external/external_mode=runner. - Upload: Studio llama
load()headless (lee el pkl) → sube el artefacto. - Deploy.
6. Troubleshooting — códigos de validate_candidate
| Código | Qué significa | Arreglo |
|---|---|---|
NO_ADAPTER | No hay subclase de ModelAdapter | Comprueba el class X(BaseAdapter/ExternalAdapter) |
AMBIGUOUS | Hay >1 clase adapter en el archivo | Deja exactamente una |
MIXED_KIND | Subclasea ambos kinds | Hereda de un solo base |
KIND_MISMATCH | cls.kind no concuerda | No toques kind (lo fija el base) |
ABSTRACT | Faltan métodos abstractos | Implementa predict/schema (+serialize/deserialize en upload) |
PREDICT_ARITY | predict no acepta input | Firma predict(self, input) |
FORBIDDEN_METHOD | External define serialize/deserialize/load | Bórralos (external no tiene artefacto) |
CONFIG_NOT_OVERRIDDEN / CONFIG_WRONG_TYPE | extract_config falta o no es @classmethod | Override como @classmethod que devuelve ExternalConnection |
LOAD_NOT_OVERRIDDEN / LOAD_WRONG_TYPE | load falta o no es @classmethod | Override load() como @classmethod que lee el pkl |
DESERIALIZE_WRONG_TYPE | deserialize no es @classmethod | Decóralo con @classmethod |
READY | ✅ válido | Importa 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