Zum Hauptinhalt springen

Cordis-Plugins entwickeln

Die Cordis-Brücke bindet einen Plugin-Baum ein, dessen Einträge in cordis.patch.yml benannt sind. Eine neue Fähigkeit entsteht durch ein Cordis-Plugin und einen zusätzlichen Eintrag, nicht durch Änderungen am Libre-WebUI-Quellcode. Diese Seite beschreibt die vom Host vorausgesetzten Konventionen.

Lies zuerst Cordis-Brücke zum Aufbau des Baums und Cordis-Konfiguration zu den Feldern der Einträge.

Die zwei Plugin-Formen

Ein Cordis-Plugin ist eine Funktion oder ein Objekt mit einer Methode apply. Der Loader löst beide Formen auf.

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

Der Loader liest den Modulnamensraum. Ein über einen Eintrag geladenes Plugin benötigt deshalb apply und inject als benannte Exporte. Ein Standardexport funktioniert ebenfalls; die mitgelieferten DSH-Plugins nutzen jedoch benannte Exporte. Verwende diese Form für einheitlichen Code.

Abhängigkeiten deklarieren

inject bildet den gesamten Abhängigkeitsmechanismus. Cordis führt das Plugin erst aus, wenn alle benannten Dienste existieren, und erneut, wenn ein Dienst zurückgezogen und wiederhergestellt wird.

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

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

Zwei Folgen sind wichtig:

  • Ein fehlender Dienst ist kein Fehler. Der Eintrag bleibt unbegrenzt und stillschweigend ausstehend. Deshalb meldet GET /api/cordis/health den Zustand jedes Dienstes statt eines einzigen booleschen Werts.
  • Injection bestimmt die Reihenfolge. Du ordnest die Ausführung nicht selbst an. Die Reihenfolge der Einträge in der mitgelieferten Komposition hat keine Ladesemantik.

Verwende für eine beim Entwickeln optionale Abhängigkeit stattdessen ctx.inject innerhalb von apply. Der Callback läuft sofort, wenn die Dienste bereits existieren, und erneut, sobald sie erscheinen:

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

ctx.inject läuft synchron, wenn seine Abhängigkeiten schon vorhanden sind. Ein spät eingebundenes Plugin registriert sich somit während apply statt in einem späteren Tick.

Einen Dienst veröffentlichen

Erweitere Service und übergib den Dienstnamen an super. Der Name ist die von Verbrauchern gelesene Eigenschaft; die Registrierung gehört der Plugin-Fiber.

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;

Bevorzuge für Verhalten eine Service-Unterklasse gegenüber ctx.reflect.provide(...). Die Unterklasse registriert den Namen einmal, bietet typisierte Methoden und wird automatisch mit ihrer Fiber zurückgezogen.

Eigentum an Nebenwirkungen

Jede Nebenwirkung muss einen Eigentümer haben. Diese einzige Regel trägt die Rücknahmegarantie der Brücke und wird am leichtesten verletzt.

NebenwirkungEigentümer
DienstregistrierungAutomatisch die Plugin-Fiber
Ereignislistenerctx.on(...) innerhalb des Plugins
Ressource mit Aufräumbedarfctx.effect(() => disposer)
Timerctx.setTimeout / ctx.setInterval
WerkzeugregistrierungDisposer aus ctx.effect zurückgeben

ctx.effect nimmt eine Funktion an, die einen Disposer zurückgibt, oder einen Generator, der Disposer liefert:

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

Das Label dient der Diagnose, nicht der Dekoration. Es benennt den Effekt, wenn dessen Aufräumen fehlschlägt.

Eine Cordis unbekannte Ressource wie Socket, Worker oder Handle zu übernehmen bedeutet, sie selbst freizugeben:

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

Was die Rücknahme verhindert

  • Registrierung in einem anderen Kontext als dem an apply übergebenen. Der Dienst überlebt dann seinen Eintrag.
  • Einen Timer mit dem globalen setTimeout erstellen. Er hält den Prozess aktiv und wird nie abgebrochen.
  • Einen externen Emitter abonnieren, ohne das Abonnement im Disposer aufzuheben.
  • In einen Singleton auf Modulebene schreiben. Das Entsorgen kann dies nicht rückgängig machen, sodass der Wert nach dem Entfernen sichtbar bleibt. Nutze stattdessen Zustand pro Fiber.

Konfiguration

Die Plugin-Konfiguration ist das Mapping config seines Eintrags. Deklariere ein Schema, damit Tippfehler beim Einbinden scheitern, statt stillschweigend einen Standardwert zu verwenden:

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

Konfigurationswerte dürfen im Kompositionsdokument !!js verwenden. Der Ausdruck wird in der Fiber des zugehörigen Eintrags mit verfügbarem Loader-Kontext ausgewertet. process.env und ctx.get(...) funktionieren, import.meta nicht. name wird nie ausgewertet, daher muss der Modulbezeichner eine literale Zeichenfolge sein.

Ein vollständiges Beispiel

Das Repository enthält zwei funktionierende Fixtures für die Testsuite der Brücke. Beide sind klein genug zum Kopieren.

Ein Modelladapter: scripts/fixtures/cordis/fake-adapter.mjs registriert eine Anbieterroute auf ctx.llm, gibt den Disposer der Registrierung aus einem Effekt zurück und beantwortet jede Anfrage mit festem Text. Das ist der gesamte Anbietervertrag: inject deklarieren, registrieren und die Registrierung besitzen.

Eine Lebenszyklusprobe: scripts/fixtures/cordis/lifecycle-probe.mjs veröffentlicht einen Dienst, abonniert ein Ereignis und zeichnet das eigene Aufräumen auf. Damit prüft der Rücknahmetest, dass beide tatsächlich nicht mehr existieren.

Binde eines der Beispiele durch einen Eintrag ein:

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

Relative Modulbezeichner werden zum Verzeichnis der Kompositionsdatei aufgelöst; Paketbezeichner werden über das Backend-Paket aufgelöst.

Ein Plugin testen

Die Testsuiten der Brücke zeigen das Vorgehen:

  • scripts/test-cordis-bridge.mjs bindet einen echten Baum in einem temporären Verzeichnis ein, prüft Dienstverfügbarkeit und Vertrag und kontrolliert, dass das Entsorgen den Dienst zurückgezogen und den Listener freigegeben hat.
  • scripts/test-cordis-bridge-http.mjs verwendet die Routen über einen echten HTTP-Server einschließlich NDJSON-Streaming.

Setze für einen Test ohne Anbieter model.provider: none und binde dein Plugin samt Engine-Einträgen ein. Hängt es von llm ab, binde einen Fixture-Adapter auf einer Route ein, die kein echter Adapter beansprucht. Setze auch hier model.provider: none, damit der Host keinen konkurrierenden Adapter für diese Route einbindet.

Ein Plugin ist nur korrekt, wenn das Entfernen seines Eintrags keine Spuren hinterlässt. Prüfe diese Folge: registrieren, beobachten, entsorgen, erneut beobachten.

Checkliste

  • apply und inject sind benannte Exporte.
  • Der Dienst ist eine Service-Unterklasse mit stabilem provide-Namen.
  • Jede Registrierung liefert einen Disposer, und jeder Disposer wird aus ctx.effect zurückgegeben.
  • Timer kommen von ctx, nicht aus globalen Funktionen.
  • Veränderlicher Zustand liegt auf der Instanz, nicht in einem Modul-Singleton.
  • Ein Schema validiert die Konfiguration.
  • Weder Komposition noch Einstellungsdokument enthalten Geheimnisse.
  • Ein Test prüft, dass nach dem Entsorgen nichts zurückbleibt.