Passa al contenuto principale

Creare plugin Cordis

Il bridge Cordis monta un albero di plugin elencati in cordis.patch.yml. Per aggiungere una funzionalità si scrive un plugin Cordis e si aggiunge una riga, senza modificare i sorgenti di Libre WebUI. Questa pagina descrive le convenzioni su cui si basa l’host.

Leggi prima Bridge Cordis per capire la struttura dell’albero e Configurazione Cordis per i campi delle righe.

Le due forme di plugin

Un plugin Cordis è una funzione oppure un oggetto con metodo apply. Il Loader risolve entrambe le forme.

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

Il Loader legge lo spazio dei nomi del modulo: un plugin caricato da una riga deve quindi esportare apply e inject con nome. Funziona anche un’esportazione predefinita, ma i plugin DSH forniti usano quella con nome: preferiscila per coerenza.

Dichiarare le dipendenze

inject è l’intero meccanismo delle dipendenze. Cordis esegue il plugin solo quando esistono tutti i servizi indicati e lo riesegue se uno viene ritirato e ripristinato.

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

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

Due conseguenze sono importanti:

  • Un servizio mancante non è un errore. La riga resta in attesa indefinitamente, senza avvisi. Per questo GET /api/cordis/health riporta lo stato per servizio invece di un singolo booleano.
  • L’iniezione determina l’ordine. Non ordinare manualmente l’esecuzione delle righe: la loro posizione nella composizione fornita non determina il caricamento.

Per una dipendenza facoltativa in fase di sviluppo, usa ctx.inject dentro apply. Il callback viene eseguito subito se i servizi esistono e nuovamente quando diventano disponibili:

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

Quando le dipendenze sono già presenti, ctx.inject è sincrono: anche un plugin montato tardi registra durante apply, non in un ciclo successivo.

Pubblicare un servizio

Estendi Service e passa il nome a super. Il nome identifica la proprietà letta dai consumatori; la registrazione appartiene alla 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;

Per servizi con comportamento, preferisci una sottoclasse Service a ctx.reflect.provide(...): registra il nome una volta, espone metodi tipizzati e viene ritirata automaticamente con la fibra.

Gestire gli effetti collaterali

Ogni effetto deve avere un proprietario. È la regola che rende possibile il ripristino garantito dal bridge, ed è facile violarla.

EffettoProprietario
Registrazione di un servizioLa fibra del plugin, automaticamente
Listener di un eventoctx.on(...) nel plugin
Risorsa da rilasciarectx.effect(() => disposer)
Timerctx.setTimeout / ctx.setInterval
Registrazione di uno strumentoIl disposer restituito da ctx.effect

ctx.effect accetta una funzione che restituisce un disposer oppure un generatore che produce disposer:

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

L’etichetta serve alla diagnostica: identifica l’effetto quando il rilascio fallisce.

Una risorsa sconosciuta a Cordis, come un socket, un worker o un handle, deve essere rilasciata esplicitamente:

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

Cosa impedisce il ripristino

  • Registrarsi su un contesto diverso da quello passato a apply: il servizio sopravvive alla riga.
  • Creare un timer con setTimeout globale: mantiene vivo il processo e non viene annullato.
  • Sottoscrivere un emettitore esterno senza annullare la sottoscrizione nel disposer.
  • Scrivere in un singleton di modulo: il rilascio non può annullarlo e il valore resta visibile dopo la rimozione della riga. Usa invece uno stato per fibra.

Configurazione

La configurazione del plugin è la mappa config della sua riga. Dichiara uno schema affinché un errore di battitura fallisca al montaggio anziché usare silenziosamente un valore predefinito:

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

I valori possono usare !!js nel documento di composizione. L’espressione viene valutata nella fibra proprietaria con il contesto del Loader: process.env e ctx.get(...) funzionano, import.meta no. name non viene mai valutato: lo specificatore del modulo deve essere una stringa letterale.

Un esempio completo

Il repository include due fixture funzionanti usate dai test del bridge, entrambe abbastanza piccole da copiare.

Adattatore di modello: scripts/fixtures/cordis/fake-adapter.mjs registra una route provider su ctx.llm, restituisce il disposer della registrazione da un effetto e risponde con testo fisso. Questo è l’intero contratto: dichiarare inject, registrarsi e possedere la registrazione.

Sonda del ciclo di vita: scripts/fixtures/cordis/lifecycle-probe.mjs pubblica un servizio, sottoscrive un evento e registra il proprio rilascio; il test di ripristino dimostra così che entrambi cessano di esistere.

Monta una delle due aggiungendo una riga:

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

Gli specificatori relativi si risolvono rispetto alla directory della composizione; quelli di pacchetto tramite il pacchetto backend.

Testare un plugin

Le suite del bridge mostrano il modello:

  • scripts/test-cordis-bridge.mjs monta un albero reale in una directory temporanea, verifica disponibilità e contratto dei servizi e controlla che il rilascio ritiri il servizio e liberi il listener.
  • scripts/test-cordis-bridge-http.mjs esercita le route con un server HTTP reale, incluso lo streaming NDJSON.

Per testare senza provider, imposta model.provider: none e monta il plugin insieme alle righe del motore. Se dipende da llm, monta un adattatore fixture su una route non usata da un adattatore reale e imposta model.provider: none, così l’host non carica un concorrente.

Un plugin è corretto solo se rimuoverne la riga non lascia tracce. Verifica la sequenza: registra, osserva, rilascia, osserva di nuovo.

Lista di controllo

  • apply e inject sono esportazioni con nome.
  • Il servizio è una sottoclasse di Service con nome provide stabile.
  • Ogni registrazione restituisce un disposer e ogni disposer viene restituito da ctx.effect.
  • I timer provengono da ctx, non da funzioni globali.
  • Lo stato mutabile appartiene all’istanza, non a un singleton di modulo.
  • Uno schema convalida la configurazione.
  • Nei documenti di composizione o impostazioni non compaiono segreti.
  • Un test verifica che il rilascio non lasci tracce del plugin.