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/healthsignale 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 bord | Propriétaire |
|---|---|
| Enregistrement d’un service | Automatiquement, la fibre du plugin |
| Écouteur d’événement | ctx.on(...) dans le plugin |
| Ressource nécessitant un nettoyage | ctx.effect(() => disposer) |
| Temporisateur | ctx.setTimeout / ctx.setInterval |
| Enregistrement d’un outil | Renvoyer 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
setTimeoutglobal. 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.mjsmonte 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.mjsutilise 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
-
applyetinjectsont des exports nommés. - Le service est une sous-classe de
Serviceavec un nomprovidestable. - 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.