Saltar al contenido principal

Crear plugins Cordis

El puente Cordis monta un árbol de plugins cuyas entradas se nombran en cordis.patch.yml. Añadir una capacidad consiste en escribir un plugin Cordis y añadir una entrada, no en editar el código de Libre WebUI. Esta página describe las convenciones en las que se apoya el host.

Lee primero Puente Cordis para entender cómo se compone el árbol y Configuración de Cordis para conocer los campos de las entradas.

Las dos formas de plugins

Un plugin Cordis es una función o un objeto con un método apply. El cargador resuelve ambas formas.

// Function form.
export function apply(ctx, config) {
// ...
}
// Object form, when the plugin also declares dependencies.
export const name = 'my-plugin';
export const inject = ['tools'];

export function apply(ctx, config) {
// ...
}

El cargador lee el espacio de nombres del módulo, así que un plugin cargado desde una entrada necesita apply e inject como exportaciones con nombre. También funciona una exportación predeterminada, pero los plugins DSH incluidos usan la forma con nombre; es preferible mantener esa coherencia.

Declarar dependencias

inject constituye todo el mecanismo de dependencias. Cordis no ejecuta el plugin hasta que existen todos los servicios indicados y vuelve a ejecutarlo si se retira y restaura alguno.

export const inject = ['tools', 'systemPrompt'];

export function apply(ctx, config) {
// `ctx.tools` and `ctx.systemPrompt` are guaranteed present here.
}

Hay dos consecuencias importantes:

  • Un servicio ausente no es un error. La entrada permanece pendiente indefinidamente y en silencio. Por eso GET /api/cordis/health informa del estado de cada servicio en lugar de un solo booleano.
  • La inyección determina el orden. No debes secuenciar las entradas; su orden en la composición incluida no determina la carga.

Para una dependencia opcional al escribir el plugin, usa ctx.inject dentro de apply. Ejecuta el callback inmediatamente si los servicios ya existen y de nuevo cada vez que aparecen:

export function apply(ctx) {
ctx.inject(['typert'], inner => {
inner.typert.lookups.register('session', {/* ... */});
});
}

ctx.inject se ejecuta de forma síncrona cuando sus dependencias ya están presentes, por lo que un plugin montado tarde se registra durante apply y no en un ciclo posterior.

Publicar un servicio

Extiende Service y pasa el nombre del servicio a super. Ese nombre es la propiedad que leen los consumidores, y el registro pertenece a la fibra del plugin.

import { Service } from '@deepseek-ai/cordis';

export class WidgetRegistry extends Service {
static provide = 'widgets';

constructor(ctx) {
super(ctx, 'widgets');
this.widgets = new Map();
}

register(widget) {
// Return the disposer so the caller's fiber owns the entry.
return this.ctx.effect(() => {
this.widgets.set(widget.id, widget);
return () => this.widgets.delete(widget.id);
}, 'widgets.register()');
}
}

export default WidgetRegistry;

Para cualquier elemento con comportamiento, prefiere una subclase de Service a ctx.reflect.provide(...): registra el nombre una vez, expone métodos tipados y se retira automáticamente junto con su fibra.

Propiedad de los efectos secundarios

Todo efecto secundario debe tener propietario. Es la regla que hace posible la garantía de reversión del puente y la más fácil de incumplir.

Efecto secundarioPropietario
Registro de servicioAutomáticamente, la fibra del plugin
Receptor de eventosctx.on(...) dentro del plugin
Recurso que necesita limpiezactx.effect(() => disposer)
Temporizadorctx.setTimeout / ctx.setInterval
Registro de herramientaDevolver la función de limpieza desde ctx.effect

ctx.effect acepta una función que devuelve una función de limpieza o un generador que produce funciones de limpieza:

ctx.effect(() => {
const registration = ctx.llm.registerAdapter(['my-route'], adapter);
return () => registration();
}, 'my-adapter.register');

La etiqueta es un diagnóstico, no un adorno: identifica el efecto cuando falla la limpieza.

Usar un recurso que Cordis desconoce, como un socket, worker o identificador, implica liberarlo por tu cuenta:

ctx.effect(() => {
const worker = startWorker();
return () => worker.terminate();
}, 'my-plugin.worker');

Qué impide la reversión

  • Registrar en un contexto distinto al recibido por apply. El servicio sobrevive entonces a su entrada.
  • Crear un temporizador con el setTimeout global. Mantiene vivo el proceso y nunca se cancela.
  • Suscribirse a un emisor externo sin anular la suscripción en la función de limpieza.
  • Escribir en un singleton del módulo. La limpieza no puede deshacerlo, así que el valor sigue visible tras eliminar la entrada; utiliza estado por fibra en su lugar.

Configuración

La configuración de un plugin es el mapa config de su entrada. Declara un esquema para que un error tipográfico falle al montar el plugin en lugar de aplicar silenciosamente un valor predeterminado:

import z from '@deepseek-ai/schemastery';

export const Config = z.object({
route: z.string().required(),
maxRetries: z.number().default(2),
});

export function apply(ctx, config) {
// `config` is validated before this runs.
}

Los valores de configuración pueden usar !!js en la composición. La expresión se evalúa en la fibra propietaria de la entrada con acceso al contexto del cargador: funcionan process.env y ctx.get(...), pero no import.meta. name nunca se evalúa, por lo que el especificador de módulo debe ser una cadena literal.

Un ejemplo completo

El repositorio incluye dos ejemplos funcionales que utiliza la batería de pruebas del puente. Ambos son lo bastante pequeños para copiarlos.

Un adaptador de modelo: scripts/fixtures/cordis/fake-adapter.mjs registra una ruta de proveedor en ctx.llm, devuelve la función de limpieza del registro desde un efecto y responde a todas las solicitudes con texto fijo. Ese es todo el contrato del proveedor: declarar inject, registrar y asumir la propiedad del registro.

Una sonda del ciclo de vida: scripts/fixtures/cordis/lifecycle-probe.mjs publica un servicio, se suscribe a un evento y registra su propia limpieza. Así, la prueba de reversión verifica que ambos dejan de existir.

Monta cualquiera de ellos añadiendo una entrada:

- id: my-adapter
name: './scripts/fixtures/cordis/fake-adapter.mjs'
config:
route: my-route

Los especificadores relativos se resuelven desde el directorio del archivo de composición; los especificadores de paquete, mediante el paquete backend.

Probar un plugin

Las baterías de pruebas del puente muestran el patrón:

  • scripts/test-cordis-bridge.mjs monta un árbol real en un directorio temporal, comprueba la disponibilidad de servicios, prueba el contrato y verifica que la limpieza retiró el servicio y liberó el receptor.
  • scripts/test-cordis-bridge-http.mjs utiliza las rutas a través de un servidor HTTP real, incluido el streaming NDJSON.

Para probar un plugin sin proveedor, establece model.provider: none y monta el plugin junto con las entradas del motor. Si depende de llm, monta un adaptador de prueba en una ruta que no ocupe ningún adaptador real y establece model.provider: none para que el host no monte otro que compita por esa ruta.

Un plugin solo es correcto si eliminar su entrada no deja rastro. Comprueba la secuencia: registrar, observar, liberar y volver a observar.

Lista de comprobación

  • apply e inject son exportaciones con nombre.
  • El servicio es una subclase de Service con un nombre provide estable.
  • Cada registro devuelve una función de limpieza y cada función de limpieza se devuelve desde ctx.effect.
  • Los temporizadores proceden de ctx, no de funciones globales.
  • El estado mutable pertenece a la instancia, no a un singleton del módulo.
  • La configuración se valida mediante un esquema.
  • No aparecen secretos en los documentos de composición o ajustes.
  • Una prueba verifica que el plugin no deja nada tras liberarse.