Status
active. Реализовано в cmd/bifrost-with-plugins/main.go. Open thread: re-enable через runtime API сломан как побочный эффект — описан ниже в “Следствия”.
Контекст
bifrost — наш LLM gateway. Стейдж крутит upstream maximhq/bifrost:latest. Чтобы добавить свой плагин (см. bifrost-mock-plugins) у bifrost есть один документированный путь — компилировать как .so shared object и указать path в config.json. Этот путь работает, но коробит:
.soплагин должен быть собран против точно той же версииgithub.com/maximhq/bifrost/coreчто и runtime. Любая разница даже в transitive dependency на patch-версию ломает loading. Цитата из upstream docs: «Even if only one transitive dependency differs by a patch version, the plugin will fail to load.»- Каждый bump bifrost-core требует пересборки всех
.so. Это сцепление на rebuild-cadence upstream’а с нашим - Cgo, gccgo и Go shared-object plugins имеют известные ABI-проблемы (
plugin: not implementedна некоторых runtime’ах)
Рассматривали
.so через PluginLoader. Стандартный путь. Brittle по причинам выше.
enabled: false в config + SyncLoadedPlugin enable вручную. Чтобы избежать ошибки unknown built-in plugin, помечаем плагин disabled в config.json. Bifrost тогда silently пропускает Bootstrap-инстанциацию, а мы вручную регистрируем после. Минус — переписывает intent юзера в config; человек, читающий config.json, видит «disabled» хотя по факту плагин активен. Plus runtime API будет рассогласован с config.
Custom PluginLoader до Bootstrap. Идея: реализовать свой PluginLoader который знает наши имена и возвращает наши плагины из in-memory registry. Минус — Config.PluginLoader ставится внутри Bootstrap(), заинжектить до — нельзя. Можно после, но Bootstrap уже отработал к этому моменту.
Fork upstream main.go + статический import + SyncLoadedPlugin. В transports/bifrost-http/server/server.go:974 есть exported метод SyncLoadedPlugin(ctx, name, plugin, placement, order). Он принимает уже инстанцированный schemas.BasePlugin и вкатывает его в plugin registry, обходя PluginLoader целиком. Метод используется в upstream’е для reload-flow. Не документирован для external custom plugins, но exported и стабилен.
Выбрали
Fork upstream main.go + SyncLoadedPlugin после Bootstrap.
Реализация (упрощённо):
// cmd/bifrost-with-plugins/main.go
func registerCustomPlugins(ctx context.Context) error {
for _, cfg := range server.Config.PluginConfigs {
switch cfg.Name {
case schemamocker.PluginName:
plugin, _ := schemamocker.New(decodePluginConfig[schemamocker.Config](cfg.Config))
return server.SyncLoadedPlugin(ctx, cfg.Name, plugin, cfg.Placement, cfg.Order)
case fhirpost.PluginName:
// ...
}
}
}
func main() {
server.Bootstrap(ctx) // ← reads config, тщетно ищет наши плагины как built-in
registerCustomPlugins(ctx) // ← injects свои
server.Start()
}Почему
- Нет ABI-coupling. Наш плагин compiled внутри того же binary что и core — версии сходятся by construction
- Нет .so-дисциплины.
go buildсобирает один статический бинарь. Распространяется как Docker image - Использует public API.
SyncLoadedPluginexported, не используем reflection или unsafe приёмы - Композируется с upstream UI/API. Bifrost после регистрации трактует наш плагин как обычный — он появляется в
/api/plugins, в дашборде, отвечает наPUT /api/plugins/<name>для disable. См. open в “Следствия” про re-enable - Стабильность. Forking upstream main — это ~50 строк Go. Когда апстрим обновляет main (новые init-этапы) — сравниваем diff и переносим. Cheaper чем переcoborka
.so
Следствия
Bootstrap логирует noisy unknown built-in plugin: <name> перед нашей регистрацией. Bifrost iterates PluginConfigs, для каждого enabled:true без Path вызывает loadBuiltinPlugin(name), который не знает наших имён → Error. Status потом перебивается на active нашим SyncLoadedPlugin, но log-line остаётся. Решено через filtering logger в cmd/bifrost-with-plugins/logger.go который дропает specific Error/Warn substrings. Лёгкий drift — при добавлении 3-го плагина нужно обновлять silenceFragments.
Re-enable через runtime API сломан (PUT /api/plugins/<name> {enabled:true} после disable). Bifrost reload-flow вызывает loadBuiltinPlugin, не находит, оставляет status: error. enabled: false через API работает (one-way). Workaround — kubectl rollout restart контейнера, наш registerCustomPlugins после Bootstrap ставит плагин обратно. Чистая починка — реализовать custom PluginLoader который знает наши имена и заменить server.Config.PluginLoader = ... после Bootstrap (там уже всё заинициализировано). ~50 строк Go, не реализовано.
UI bake требует Node-stage в Dockerfile. Bifrost UI это React/Vite app которая упаковывается в upstream-binary через //go:embed all:ui поверх transports/bifrost-http/ui/ build-output. Когда мы заменяем upstream-binary своим — UI исчезает. Multi-stage Dockerfile клонит bifrost@core/v1.5.8, делает npm run build, копирует output в cmd/bifrost-with-plugins/ui/, наш Go-stage embeds. Local dev — make build-ui (scripts/build-ui.sh).
Bifrost-core version coupling. В go.mod пинится github.com/maximhq/bifrost/core@v1.5.8. Bump core требует:
- update
go.mod - bump
BIFROST_CORE_VERSIONвMakefile/Dockerfile/scripts/build-ui.sh(UI должна совпадать с binary’ной версией) - audit
core/schemas/plugin.goна breaking changes вLLMPlugininterface
Открытые вопросы
- Реализовать custom
PluginLoaderдля починки re-enable. Triggered by — operators начнут жаловаться на rollout-restart, или захотим A/B toggling без redeploy - Submit
schema-mockerupstream. Плагин domain-agnostic (medical preset за blank import),LLMPlugininterface стандартный. Если merged — мы перестаём поддерживать форк, но возможно появится PR-coupling на upstream. Trade-off
Связано
- bifrost-mock-plugins — entity-page плагинов, реализующих этот pattern
- bifrost — vendor сам по себе
- Source ref:
_bifrost-upstream/transports/bifrost-http/server/server.go:974—SyncLoadedPlugindefinition
Источники
Сноски
-
Bifrost docs, accessed 2026-05-17, https://docs.getbifrost.ai/plugins/writing-go-plugin. ↩
-
Bifrost issue про per-request context-keys, accessed 2026-05-17, https://github.com/maximhq/bifrost/issues/2029. ↩