Pular para o conteúdo principal

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/health informa 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 colateralResponsável
Registro de um serviçoA fibra do plugin, automaticamente
Listener de eventosctx.on(...) dentro do plugin
Recurso que exige limpezactx.effect(() => disposer)
Temporizadorctx.setTimeout / ctx.setInterval
Registro de uma ferramentaRetornar 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 setTimeout global. 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.mjs monta 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.mjs exercita 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

  • apply e inject são exportações nomeadas.
  • O serviço é uma subclasse de Service com um nome provide está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.