Criação de plugins Cordis
A ponte Cordis monta uma árvore de plugins cujas entradas são declaradas em cordis.patch.yml. Para adicionar uma capacidade, escreva um plugin Cordis e adicione uma entrada, sem alterar o código-fonte do Libre WebUI. Esta página apresenta as convenções das quais o host depende.
Leia primeiro Ponte Cordis para entender a composição da árvore e Configuração do Cordis para conhecer os campos das entradas.
As duas formas de plugin
Um plugin Cordis pode ser uma função ou um objeto com um método apply. O Loader resolve ambas as 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) {
// ...
}
O Loader lê o namespace do módulo, portanto um plugin carregado por uma entrada precisa fornecer apply e inject como exportações nomeadas. Uma exportação padrão também funciona, mas os plugins DSH incluídos usam exportações nomeadas; prefira essa forma para manter a consistência.
Declaração de dependências
inject é o mecanismo completo de dependências. O Cordis só executa o plugin quando todos os serviços nomeados existem e o executa novamente quando um deles é retirado e restaurado.
export const inject = ['tools', 'systemPrompt'];
export function apply(ctx, config) {
// `ctx.tools` and `ctx.systemPrompt` are guaranteed present here.
}
Duas consequências são importantes:
- Um serviço ausente não é um erro. A entrada permanece pendente indefinidamente, sem aviso. Por isso,
GET /api/cordis/healthinforma o estado de cada serviço, em vez de um único booleano. - A injeção determina a ordem. Você não precisa ordenar a execução das entradas; a ordem das entradas na composição incluída não determina o carregamento.
Para uma dependência considerada opcional durante a criação, use ctx.inject dentro de apply. Ele executa o callback imediatamente se os serviços já existem e novamente sempre que eles aparecem:
export function apply(ctx) {
ctx.inject(['typert'], inner => {
inner.typert.lookups.register('session', {/* ... */});
});
}
Quando as dependências já estão presentes, ctx.inject executa de forma síncrona. Assim, um plugin montado posteriormente ainda se registra durante apply, sem esperar um próximo ciclo de eventos.
Publicação de um serviço
Estenda Service e passe o nome do serviço para super. Esse nome define a propriedade lida pelos consumidores, e o registro pertence à fibra do 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 serviços com comportamento, prefira uma subclasse de Service a ctx.reflect.provide(...): ela registra o nome uma única vez, oferece métodos tipados e é retirada automaticamente junto com sua fibra.
Responsabilidade pelos efeitos colaterais
Todo efeito colateral precisa ter um responsável. Essa é a regra que sustenta a garantia de reversão da ponte e também a mais fácil de violar.
| Efeito colateral | Responsável |
|---|---|
| Registro de um serviço | A fibra do plugin, automaticamente |
| Listener de eventos | ctx.on(...) dentro do plugin |
| Recurso que exige limpeza | ctx.effect(() => disposer) |
| Temporizador | ctx.setTimeout / ctx.setInterval |
| Registro de uma ferramenta | Retornar a função de descarte de ctx.effect |
ctx.effect recebe uma função que retorna uma função de descarte ou um gerador que fornece funções de descarte:
ctx.effect(() => {
const registration = ctx.llm.registerAdapter(['my-route'], adapter);
return () => registration();
}, 'my-adapter.register');
O rótulo é uma informação de diagnóstico: identifica o efeito quando o encerramento falha.
Assumir um recurso que o Cordis não conhece, como um socket, um worker ou um identificador, exige descartá-lo por conta própria:
ctx.effect(() => {
const worker = startWorker();
return () => worker.terminate();
}, 'my-plugin.worker');
O que impede a reversão
- Registrar em um contexto diferente daquele passado para
apply. O serviço continuará existindo após a remoção da entrada. - Criar um temporizador com o
setTimeoutglobal. Ele mantém o processo ativo e nunca é cancelado. - Assinar um emissor externo sem cancelar a assinatura na função de descarte.
- Escrever em um singleton do módulo. O descarte não desfaz essa alteração, e o valor permanece visível após a remoção da entrada. Use estado por fibra.
Configuração
A configuração do plugin é o mapeamento config da sua entrada. Declare um esquema para que erros de digitação causem falha na montagem, em vez de usar silenciosamente um valor padrão:
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.
}
Os valores de configuração podem usar !!js no documento de composição. A expressão é avaliada na fibra da entrada à qual pertence, com acesso ao contexto do Loader: process.env e ctx.get(...) funcionam, mas import.meta não. name nunca é avaliado, portanto o especificador de módulo da entrada deve ser uma string literal.
Um exemplo completo
O repositório inclui duas fixtures funcionais usadas pela suíte de testes da ponte. Ambas são pequenas o suficiente para servir como exemplos copiáveis.
Um adaptador de modelos: scripts/fixtures/cordis/fake-adapter.mjs registra uma rota de provedor em ctx.llm, retorna a função de descarte do registro a partir de um efeito e responde a todas as solicitações com texto fixo. Esse é todo o contrato do provedor: declarar inject, registrar e assumir a responsabilidade pelo registro.
Uma sonda de ciclo de vida: scripts/fixtures/cordis/lifecycle-probe.mjs publica um serviço, assina um evento e registra seu próprio encerramento. Assim, o teste de reversão verifica que tanto o serviço quanto a assinatura realmente deixam de existir.
Monte qualquer um deles adicionando uma entrada:
- id: my-adapter
name: './scripts/fixtures/cordis/fake-adapter.mjs'
config:
route: my-route
Especificadores relativos são resolvidos a partir do diretório do arquivo de composição; especificadores de pacote são resolvidos pelo pacote do backend.
Como testar um plugin
As suítes da ponte demonstram o padrão:
scripts/test-cordis-bridge.mjsmonta uma árvore real em um diretório temporário, verifica a disponibilidade do serviço, exercita o contrato e confirma que o descarte retirou o serviço e liberou o listener.scripts/test-cordis-bridge-http.mjsexercita as rotas por um servidor HTTP real, incluindo streaming NDJSON.
Para testar um plugin sem provedor, defina model.provider: none e monte seu plugin junto com as entradas do mecanismo. Para testar um plugin que depende de llm, monte um adaptador de fixture em uma rota não usada por nenhum adaptador real e defina model.provider: none, evitando que o host monte um adaptador concorrente nessa rota.
Um plugin só está correto se a remoção da sua entrada não deixar recursos para trás. Verifique a sequência: registrar, observar, descartar e observar novamente.
Lista de verificação
-
applyeinjectsão exportações nomeadas. - O serviço é uma subclasse de
Servicecom um nomeprovideestável. - Todo registro retorna uma função de descarte, e toda função de descarte é retornada por
ctx.effect. - Os temporizadores vêm de
ctx, não de funções globais. - O estado mutável pertence à instância, não a um singleton do módulo.
- Um esquema valida a configuração.
- Nenhum segredo aparece no documento de composição ou de configurações.
- Um teste confirma que nada fica para trás após o descarte do plugin.