Aller au contenu principal

Créer des plugins Cordis

Le pont Cordis monte un arbre de plugins dont les entrées sont nommées dans cordis.patch.yml. Ajouter une fonctionnalité consiste à écrire un plugin Cordis et à ajouter une entrée, sans modifier les sources de Libre WebUI. Cette page présente les conventions sur lesquelles repose l’hôte.

Lisez d’abord Pont Cordis pour comprendre l’assemblage de l’arbre, puis Configuration Cordis pour les champs des entrées.

Les deux formes de plugins

Un plugin Cordis est une fonction ou un objet doté d’une méthode apply. Le chargeur résout les deux formes.

// 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) {
// ...
}

Le chargeur lit l’espace de noms du module : un plugin chargé depuis une entrée doit donc fournir apply et inject comme exports nommés. Un export par défaut fonctionne aussi, mais les plugins DSH fournis utilisent la forme nommée ; privilégiez-la pour rester cohérent.

Déclarer les dépendances

inject constitue tout le mécanisme de dépendance. Cordis n’exécute le plugin que lorsque chaque service nommé existe, puis le relance si l’un de ces services est retiré et rétabli.

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

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

Deux conséquences sont importantes :

  • Un service manquant n’est pas une erreur. L’entrée reste silencieusement en attente indéfiniment. C’est pourquoi GET /api/cordis/health signale l’état de chaque service plutôt qu’un simple booléen.
  • L’injection détermine l’ordre. Vous ne séquencez jamais les entrées vous-même ; leur ordre dans la composition fournie ne détermine pas leur chargement.

Pour une dépendance facultative lors de la création du plugin, utilisez plutôt ctx.inject dans apply. Le rappel s’exécute immédiatement si les services existent déjà, puis à chaque fois qu’ils apparaissent :

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

ctx.inject s’exécute de façon synchrone lorsque ses dépendances sont déjà présentes. Un plugin monté tardivement s’enregistre donc pendant apply, sans attendre un cycle ultérieur.

Publier un service

Étendez Service et passez le nom du service à super. Ce nom est la propriété lue par les consommateurs, et l’enregistrement appartient à la fibre du 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;

Préférez une sous-classe de Service à ctx.reflect.provide(...) pour tout élément doté d’un comportement : la sous-classe enregistre le nom une seule fois, expose des méthodes typées et est retirée automatiquement avec sa fibre.

Posséder les effets de bord

Chaque effet de bord doit avoir un propriétaire. Cette règle rend possible la garantie de retour arrière du pont, et c’est la plus facile à enfreindre.

Effet de bordPropriétaire
Enregistrement d’un serviceAutomatiquement, la fibre du plugin
Écouteur d’événementctx.on(...) dans le plugin
Ressource nécessitant un nettoyagectx.effect(() => disposer)
Temporisateurctx.setTimeout / ctx.setInterval
Enregistrement d’un outilRenvoyer la fonction de destruction depuis ctx.effect

ctx.effect accepte une fonction qui renvoie une fonction de destruction, ou un générateur qui en produit :

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

Le libellé sert au diagnostic, pas à la décoration : il nomme l’effet lorsque son nettoyage échoue.

Prendre en charge une ressource inconnue de Cordis, comme un socket, un worker ou un handle, implique de la libérer vous-même :

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

Ce qui empêche le retour arrière

  • Enregistrer dans un contexte différent de celui passé à apply. Le service survit alors à son entrée.
  • Créer un temporisateur avec le setTimeout global. Il maintient le processus actif et n’est jamais annulé.
  • S’abonner à un émetteur externe sans se désabonner dans la fonction de destruction.
  • Écrire dans un singleton au niveau du module. La destruction ne peut pas l’annuler : la valeur reste visible après le retrait de l’entrée. Utilisez plutôt un état propre à chaque fibre.

Configuration

La configuration d’un plugin est la correspondance config de son entrée. Déclarez un schéma pour qu’une faute de frappe provoque une erreur au montage plutôt que l’utilisation silencieuse d’une valeur par défaut :

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.
}

Les valeurs de configuration peuvent utiliser !!js dans le document de composition. L’expression est évaluée dans la fibre de l’entrée propriétaire avec le contexte du chargeur accessible : process.env et ctx.get(...) fonctionnent, mais pas import.meta. name n’est jamais évalué ; le spécificateur de module d’une entrée doit donc être une chaîne littérale.

Un exemple complet

Le dépôt fournit deux exemples fonctionnels utilisés par la suite de tests du pont. Tous deux sont assez petits pour être copiés.

Un adaptateur de modèle : scripts/fixtures/cordis/fake-adapter.mjs enregistre une route de fournisseur dans ctx.llm, renvoie la fonction de destruction de l’enregistrement depuis un effet et répond à chaque requête avec un texte fixe. Le contrat du fournisseur se résume à déclarer inject, enregistrer et posséder l’enregistrement.

Une sonde de cycle de vie : scripts/fixtures/cordis/lifecycle-probe.mjs publie un service, s’abonne à un événement et enregistre son propre nettoyage. Le test de retour arrière vérifie ainsi que les deux cessent effectivement d’exister.

Montez l’un ou l’autre en ajoutant une entrée :

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

Les spécificateurs relatifs sont résolus depuis le répertoire du fichier de composition ; les spécificateurs nus le sont via le package backend.

Tester un plugin

Les suites du pont illustrent la méthode :

  • scripts/test-cordis-bridge.mjs monte un arbre réel dans un répertoire temporaire, vérifie la disponibilité des services, exerce le contrat et vérifie que la destruction a retiré le service et libéré l’écouteur.
  • scripts/test-cordis-bridge-http.mjs utilise les routes via un véritable serveur HTTP, y compris les flux NDJSON.

Pour tester un plugin sans fournisseur, définissez model.provider: none et montez votre plugin avec les entrées du moteur. Pour un plugin qui dépend de llm, montez un adaptateur de test sur une route qu’aucun véritable adaptateur ne revendique et définissez model.provider: none afin que l’hôte n’y monte pas d’adaptateur concurrent.

Un plugin n’est correct que si le retrait de son entrée ne laisse aucune trace. Vérifiez cette séquence : enregistrer, observer, détruire, observer à nouveau.

Liste de vérification

  • apply et inject sont des exports nommés.
  • Le service est une sous-classe de Service avec un nom provide stable.
  • Chaque enregistrement renvoie une fonction de destruction, et chaque fonction de destruction est renvoyée depuis ctx.effect.
  • Les temporisateurs viennent de ctx, pas des fonctions globales.
  • L’état modifiable appartient à l’instance, pas à un singleton du module.
  • La configuration est validée par un schéma.
  • Aucun secret ne figure dans le document de composition ou de paramètres.
  • Un test vérifie que le plugin ne laisse rien après sa destruction.