Разработка Go-плагина

Пошаговое руководство по созданию in-process плагина Meridian на Go в виде разделяемой библиотеки (.so).

Обзор

В этом руководстве описано создание in-process плагина Meridian на Go на основе примера hello-world. Вы узнаете, как устроен проект плагина, какие функции нужно реализовать, как собрать разделяемую библиотеку и подключить её к Meridian.

Динамические плагины загружает только сборка Meridian с динамической линковкой. Стандартные образы собраны статически; используйте образы с суффиксом -dyn. Подробнее — в разделе Начало работы.

Предварительные требования

  • Go той же версии, что и у вашего выпуска Meridian (текущие выпуски собраны Go 1.26.5).
  • Linux или macOS — плагины Go не поддерживаются в Windows.
  • Meridian установлен и настроен.
  • Базовое знание Go.

Версия Go в go.mod плагина должна совпадать с версией, которой собран Meridian. Проверить версию Go и зависимостей бинарного файла можно командой go version -m <путь к бинарному файлу>.

Структура проекта

Минимальный проект плагина:

hello-world/
├── main.go          # Реализация плагина
├── go.mod           # Определение модуля Go
├── go.sum           # Контрольные суммы зависимостей
├── Makefile         # Автоматизация сборки
└── .gitignore       # Игнорируемые файлы

Шаг 1. Инициализация проекта

Создайте каталог и инициализируйте модуль Go:

mkdir my-plugin
cd my-plugin
go mod init github.com/yourusername/my-plugin

Добавьте зависимость от схем Meridian, указав версию, соответствующую вашему выпуску:

go get github.com/neria-cloud/meridian-base/core@v1.7.13

Файл go.mod будет выглядеть так:

module github.com/yourusername/my-plugin

go 1.26.5

require github.com/neria-cloud/meridian-base/core v1.7.13

Шаг 2. Реализация интерфейса плагина

Создайте main.go с функциями плагина. Ниже — пример hello-world целиком:

package main

import (
	"fmt"

	"github.com/neria-cloud/meridian-base/core/schemas"
)

const (
	transportPreHookKey  schemas.BifrostContextKey = "hello-world-plugin-transport-pre-hook"
	transportPostHookKey schemas.BifrostContextKey = "hello-world-plugin-transport-post-hook"
	preHookKey           schemas.BifrostContextKey = "hello-world-plugin-pre-hook"
)

// Init вызывается при загрузке плагина.
// config — конфигурация плагина из его записи (поле config).
func Init(config any) error {
	fmt.Println("Init called")
	// Инициализация ресурсов: подключения к БД, клиенты внешних API и т. п.
	return nil
}

// GetName возвращает системный идентификатор плагина (обязательная функция).
func GetName() string {
	return "hello-world"
}

// HTTPTransportPreHook перехватывает HTTP-запрос ДО входа в ядро Meridian.
// Запрос изменяется на месте. Вернуть (*HTTPResponse, nil) — завершить обработку досрочно.
// Вызывается только для HTTP-транспорта.
func HTTPTransportPreHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest) (*schemas.HTTPResponse, error) {
	// ctx.Log — структурированный лог в контексте плагина (рекомендуется)
	ctx.Log(schemas.LogLevelInfo, "HTTPTransportPreHook called")

	// Чтение заголовков без учёта регистра (рекомендуется)
	contentType := req.CaseInsensitiveHeaderLookup("Content-Type")
	fmt.Printf("Content-Type: %s\n", contentType)

	// Изменение запроса на месте
	req.Headers["x-hello-world-plugin"] = "transport-pre-hook-value"

	// Значение в контексте доступно остальным хукам этого запроса
	ctx.SetValue(transportPreHookKey, "transport-pre-hook-value")

	// nil — продолжить обработку; &schemas.HTTPResponse{} — завершить досрочно
	return nil, nil
}

// HTTPTransportPostHook перехватывает HTTP-ответ ПОСЛЕ выхода из ядра.
// Ответ изменяется на месте. Вызывается в порядке, обратном pre-хукам,
// и только для НЕпотоковых ответов.
func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error {
	ctx.Log(schemas.LogLevelInfo, "HTTPTransportPostHook called")

	// Изменение заголовков ответа
	resp.Headers["x-hello-world-plugin"] = "transport-post-hook-value"

	// Чтение значения, записанного в pre-хуке
	if val := ctx.Value(transportPreHookKey); val != nil {
		fmt.Printf("Context value: %v\n", val)
	}

	// nil — продолжить; error — завершить с ошибкой
	return nil
}

// HTTPTransportStreamChunkHook перехватывает фрагменты потокового ответа ДО отправки клиенту.
// Вызывается в порядке, обратном pre-хукам, и только для ПОТОКОВЫХ ответов.
func HTTPTransportStreamChunkHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, chunk *schemas.BifrostStreamChunk) (*schemas.BifrostStreamChunk, error) {
	ctx.Log(schemas.LogLevelInfo, "HTTPTransportStreamChunkHook called")

	// chunk — типизированная структура, заполнено ровно одно поле:
	// BifrostTextCompletionResponse, BifrostChatResponse, BifrostResponsesStreamResponse,
	// BifrostSpeechStreamResponse, BifrostTranscriptionStreamResponse,
	// BifrostImageGenerationStreamResponse или BifrostError

	// Вернуть chunk без изменений — пропустить дальше
	return chunk, nil

	// Вернуть (nil, nil) — отбросить фрагмент;
	// вернуть изменённый chunk — заменить его.
}

// PreRequestHook вызывается один раз на запрос (не на каждую попытку fallback).
// Это фаза маршрутизации: здесь принимаются решения о провайдере, модели и fallback'ах.
// Изменения провайдера, модели и fallback'ов сохраняются для всех попыток.
// Ошибка не блокирует запрос (журналируется и пропускается).
func PreRequestHook(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) error {
	ctx.Log(schemas.LogLevelInfo, "PreRequestHook called")
	// Плагин, не участвующий в маршрутизации, возвращает nil
	return nil
}

// PreLLMHook вызывается перед отправкой запроса провайдеру.
// Здесь можно изменить запрос или завершить обработку досрочно.
func PreLLMHook(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.LLMPluginShortCircuit, error) {
	ctx.Log(schemas.LogLevelInfo, "PreLLMHook called")
	ctx.SetValue(preHookKey, "pre-hook-value")
	return req, nil, nil
}

// PostLLMHook вызывается после получения ответа провайдера.
// Здесь можно изменить ответ или обработать ошибку.
func PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) {
	ctx.Log(schemas.LogLevelInfo, "PostLLMHook called")
	return resp, bifrostErr, nil
}

// Cleanup вызывается при остановке Meridian.
func Cleanup() error {
	fmt.Println("Cleanup called")
	// Освобождение ресурсов: закрытие подключений, сброс буферов и т. п.
	return nil
}

В этом каркасе показаны только хуки LLM и HTTP-транспорта. Плагин, которому нужно перехватывать трафик MCP-шлюза (проверки на уровне вызова инструмента, изменение параметров подключения), также может экспортировать PreMCPHook, PostMCPHook, PreMCPConnectionHook и PostMCPConnectionHook — см. обзор MCP-хуков ниже.

Описание функций

Init(config any) error

Вызывается один раз при загрузке плагина. Используйте её, чтобы:

  • разобрать конфигурацию плагина;
  • установить подключения к базам данных;
  • создать клиентов внешних API;
  • проверить обязательные переменные окружения.
func Init(config any) error {
	cfg, ok := config.(map[string]interface{})
	if !ok {
		return fmt.Errorf("invalid config format")
	}

	apiKey := cfg["api_key"].(string)
	// Инициализация ресурсов
	_ = apiKey
	return nil
}

GetName() string

Возвращает уникальный идентификатор плагина. Под этим именем плагин отображается в логах, в статусе и в разделе Plugin Logs деталей запроса.

ctx.Log(level, msg)

Добавляет структурированную запись в лог, привязанную к текущему плагину. Используйте её вместо fmt.Println: записи собираются по каждому запросу и отображаются в деталях запроса в разделе Plugin Logs.

ctx.Log(schemas.LogLevelInfo, "processing request")
ctx.Log(schemas.LogLevelWarn, "cache miss, falling back to provider")
ctx.Log(schemas.LogLevelError, "failed to parse response body")

Уровни:

КонстантаЗначение
schemas.LogLevelDebug"debug"
schemas.LogLevelInfo"info"
schemas.LogLevelWarn"warn"
schemas.LogLevelError"error"

Особенности:

  • Потокобезопасность — можно вызывать из параллельных горутин.
  • Привязка к плагину — каждая запись автоматически помечается именем плагина.
  • Метка времени — записи содержат время в миллисекундах Unix.
  • Без контекста плагина — пустая операция — вызов вне хука безопасен и игнорируется.

ctx.GetModelInfo(provider, model)

Возвращает цены и характеристики модели — те же данные, что отдаёт эндпоинт /v1/models. Доступна из любого хука.

info := ctx.GetModelInfo(schemas.Anthropic, "claude-opus-5")
if info == nil {
	return // неизвестная модель или каталог моделей не настроен
}

if info.ContextLength != nil {
	ctx.Log(schemas.LogLevelInfo, fmt.Sprintf("context window: %d tokens", *info.ContextLength))
}
if info.IsDeprecated {
	ctx.Log(schemas.LogLevelWarn, "model is deprecated")
}

Результат — *schemas.Model:

ПолеТипОписание
IDstringЗапрошенная строка модели
Pricing*schemas.PricingБазовые ставки за токен в виде десятичных строк: Prompt, Completion, Image, InputCacheRead, InputCacheWrite, WebSearch
ContextLength*intРазмер контекстного окна; при отсутствии отдельного значения в каталоге — максимум входных токенов
MaxInputTokens*intПредел входных токенов
MaxOutputTokens*intПредел выходных токенов
Architecture*schemas.ArchitectureМодальность, токенизатор, входные и выходные модальности
SupportedParameters[]stringПараметры запроса, которые принимает модель
IsDeprecatedboolПризнак выведенной из эксплуатации модели
AdditionalAttributesmap[string]stringРедакторские метаданные (описание, теги)

Особенности:

  • Возвращает nil, если модель неизвестна каталогу или каталог не настроен — всегда проверяйте результат.
  • Свежая копия — возвращённую структуру можно изменять, это ни на что не влияет.
  • Провайдер обязателен — передавайте конкретного провайдера, обслуживающего модель. В pre-хуке берите его из req.GetRequestFields(), в post-хуке — из RoutingInfo ответа, а не выводите из строки модели.
  • Каждое поле необязательно — каталог заполнен настолько, насколько заполнена карточка модели у провайдера; проверяйте и отдельные указатели.

ctx.CalculateCost(resp)

Возвращает стоимость завершённого ответа в долларах.

func PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) {
	cost := ctx.CalculateCost(resp) // вычислить синхронно
	go func() {
		reportToBilling(cost) // затем использовать значение где угодно
	}()
	return resp, bifrostErr, nil
}

Предпочитайте этот метод арифметике над GetModelInfo(...).Pricing. То поле содержит только базовые ставки, а CalculateCost применяет полную схему ценообразования:

  • уровни цен для длинного контекста (128k / 200k / 272k);
  • ставки batch, priority, flex и fast;
  • стоимость чтения и записи кэша;
  • цепочку резолвинга провайдера и режима запроса, чтобы псевдонимы и модели с префиксом провайдера тоже разрешались;
  • переопределения цен виртуального ключа, пользователя или ключа провайдера, действующие для этого запроса.

Вызывайте ctx.CalculateCost синхронно внутри хука. Метод читает governance-атрибуты (идентификаторы пользователя, виртуального ключа, выбранного ключа) из контекста запроса, и это чтение небезопасно после отмены запроса. Если стоимость нужна в фоновой горутине, вычислите её в хуке и передайте значение float64, как показано выше.

Возвращает 0, если каталог моделей не настроен или ответ не содержит тарифицируемого использования.

HTTPTransportPreHook(ctx, req)

Только HTTP-транспорт. Перехватывает запрос до входа в ядро Meridian. Используйте, чтобы:

  • изменить заголовки, тело или query-параметры запроса на месте;
  • завершить обработку досрочно собственным ответом;
  • сохранить значения в BifrostContext для других хуков.

Особенности:

  • принимает сериализуемый *HTTPRequest;
  • поля req.Headers, req.Body, req.Query изменяются напрямую;
  • (nil, nil) — перейти к следующему плагину или обработчику;
  • (*HTTPResponse, nil) — завершить досрочно с ответом;
  • (nil, error) — завершить досрочно с ошибкой.

HTTPTransportPostHook(ctx, req, resp)

Только HTTP-транспорт. Перехватывает ответ после выхода из ядра Meridian. Используйте, чтобы:

  • изменить заголовки или тело ответа на месте;
  • журналировать или отслеживать ответы;
  • прочитать значения контекста, записанные в pre-хуке.

Особенности:

  • принимает *HTTPRequest и *HTTPResponse;
  • поля resp.Headers, resp.Body, resp.StatusCode изменяются напрямую;
  • nil — перейти к следующему плагину или обработчику;
  • error — завершить с ошибкой и пропустить оставшиеся post-хуки;
  • вызывается в обратном порядке относительно pre-хуков;
  • не вызывается для потоковых ответов — используйте HTTPTransportStreamChunkHook.

HTTPTransportStreamChunkHook(ctx, req, chunk)

Только HTTP-транспорт. Перехватывает фрагменты потокового ответа до записи клиенту. Используйте, чтобы:

  • изменять фрагменты в реальном времени;
  • фильтровать отдельные фрагменты;
  • журналировать или отслеживать потоковые данные.

Особенности:

  • принимает типизированную структуру *schemas.BifrostStreamChunk, а не сырые байты;
  • в структуре заполнено одно поле в зависимости от типа ответа (chat, text completion, responses, speech, transcription, image generation или ошибка);
  • (chunk, nil) — передать без изменений;
  • (nil, nil) — отбросить фрагмент;
  • (modifiedChunk, nil) — заменить фрагмент;
  • (nil, error) — отправить клиенту ошибку и остановить поток;
  • вызывается в обратном порядке относительно pre-хуков.

HTTPTransportPostHook не вызывается для потоковых ответов. Для потоковых данных используйте HTTPTransportStreamChunkHook.

Чтение заголовков и query-параметров. Используйте вспомогательные методы без учёта регистра:

// Правильно — поиск без учёта регистра
contentType := req.CaseInsensitiveHeaderLookup("Content-Type")
apiKey := req.CaseInsensitiveQueryLookup("api_key")

// Работает при любом написании
contentType = req.CaseInsensitiveHeaderLookup("content-type")
contentType = req.CaseInsensitiveHeaderLookup("CONTENT-TYPE")

// Для записи заголовков — прямой доступ к map
req.Headers["X-Custom-Header"] = "value"

Методы CaseInsensitiveHeaderLookup и CaseInsensitiveQueryLookup гарантируют корректную работу плагина независимо от того, в каком регистре клиент передал имена заголовков и параметров.

PreRequestHook(ctx, req)

Вызывается один раз на запрос, до первого обращения к провайдеру и до PreLLMHook. Это фаза маршрутизации: здесь плагины решают, к какому провайдеру, модели и с какими fallback'ами направить запрос.

Используйте для:

  • решений о маршрутизации — правила governance, балансировка виртуального ключа, маршрутизация по региону или тарифу;
  • определения провайдера — заполнение req.Provider для моделей без префикса (встроенный model-catalog-resolver делает это последним);
  • построения цепочки fallback'ов — заполнение req.Fallbacks по политике.

Чем отличается от PreLLMHook:

АспектPreRequestHookPreLLMHook
ВыполняетсяОдин раз на запросНа каждую попытку обращения к провайдеру (повторно при каждом fallback)
Изменение провайдера и моделиСохраняется и действует для всех попытокНе действует для Provider/Model (перезаписывается ядром)
НазначениеРешения о маршрутизацииПреобразование запроса, кэширование, проверки
Досрочное завершениеНетДа (можно вернуть синтетический ответ)
ОшибкаНе блокирует (журналируется, конвейер продолжается)Не блокирует (журналируется, конвейер продолжается)

Пример маршрутизации:

func PreRequestHook(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) error {
	provider, model, _ := req.GetRequestFields()

	// Запросы премиум-тарифа направляются к более быстрому провайдеру
	if tier := ctx.Value(schemas.BifrostContextKey("x-tier")); tier == "premium" && provider == "openai" {
		req.SetProvider(schemas.Anthropic)
		req.SetModel("claude-3-5-sonnet")
		// Запись в лог механизма маршрутизации: почему принято решение
		ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, schemas.LogLevelInfo,
			fmt.Sprintf("Routed %s to anthropic/claude-3-5-sonnet (tier=premium)", model))
		schemas.AppendToContextList(ctx, schemas.BifrostContextKeyRoutingEnginesUsed,
			schemas.RoutingEngineRoutingRule)
	}
	return nil
}

Два полезных вспомогательных вызова для логики маршрутизации:

  • ctx.AppendRoutingEngineLog(engine, level, message) — структурированная запись, видимая в инструментах наблюдаемости. Объясняйте в ней, почему принято решение о маршрутизации.
  • schemas.AppendToContextList(ctx, schemas.BifrostContextKeyRoutingEnginesUsed, engineName) — фиксирует, какие механизмы маршрутизации участвовали. В телеметрии отображается как routing.engines_used.

Порядок плагинов важен для маршрутизации. Встроенные механизмы выполняются в PreRequestHook в таком порядке: правила маршрутизации governance → балансировка виртуального ключа → адаптивная балансировка (Enterprise) → model-catalog-resolver (последний). Собственный плагин встраивается через placement и order — см. порядок выполнения хуков.

Ошибки не блокируют запрос. Ошибка из PreRequestHook журналируется как предупреждение, но не отклоняет запрос: конвейер переходит к следующему плагину. После всех PreRequestHook ядро проверяет req.Provider; неразрешённый провайдер возвращает клиенту 400.

PreLLMHook(ctx, req)

Вызывается перед каждым обращением к провайдеру. Используйте, чтобы:

  • изменить параметры запроса;
  • добавить журналирование или мониторинг;
  • реализовать кэширование (проверить кэш и вернуть сохранённый ответ);
  • применить правила governance (лимиты запросов, бюджеты);
  • завершить обработку досрочно, не обращаясь к провайдеру.

Пример досрочного завершения:

func PreLLMHook(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.LLMPluginShortCircuit, error) {
	// Вернуть ответ из кэша без обращения к провайдеру
	if cachedResponse := checkCache(req); cachedResponse != nil {
		return req, &schemas.LLMPluginShortCircuit{
			Response: cachedResponse,
		}, nil
	}
	return req, nil, nil
}

PostLLMHook(ctx, resp, bifrostErr)

Вызывается после ответа провайдера (или после досрочного завершения). Используйте, чтобы:

  • преобразовать ответ;
  • журналировать данные ответа;
  • сохранить ответ в кэш;
  • обработать ошибку или реализовать собственную логику fallback;
  • добавить метаданные.

Пример преобразования ответа:

func PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) {
	if resp != nil && resp.ChatResponse != nil {
		// Добавить метаданные
		resp.ChatResponse.ExtraFields.RawResponse = map[string]interface{}{
			"plugin_processed": true,
			"timestamp":        time.Now().Unix(),
		}
	}
	return resp, bifrostErr, nil
}

Обзор MCP-хуков

Плагин может перехватывать трафик MCP-шлюза (поверхности Model Context Protocol в Meridian), экспортируя любые из четырёх необязательных функций. Они независимы от LLM-хуков: LLM-хуки срабатывают для запросов вида /v1/chat/completions и /v1/responses, MCP-хуки — для операций ping, list_tools, execute_tool и connect по настроенным MCP-клиентам.

MCP-поверхность делится на два этапа жизненного цикла, у каждого своя пара хуков:

ЭтапПара хуковТип запросаКогда срабатывает
Подключение (настройка транспорта)PreMCPConnectionHook / PostMCPConnectionHook*BifrostMCPConnectRequestОдин раз на MCP-клиент при установлении транспорта
Конверт (каждый вызов)PreMCPHook / PostMCPHook*BifrostMCPRequestНа каждый вызов ping / list_tools / execute_tool после успешного подключения

Подключение несёт транспортные параметры (URL, заголовки, аргументы stdio), которые не имеют смысла после установления соединения, поэтому обрабатывается отдельным типизированным интерфейсом. Плагин может реализовать обе пары, только пару конверта или только пару подключения — экспортируйте нужные функции. Загрузчик считает каждый MCP-хук необязательным.

Досрочное завершение подключения использует *MCPConnectionShortCircuit (типизированный ответ подключения), досрочное завершение конверта — *MCPPluginShortCircuit (обёртка конверта). Они похожи, но не взаимозаменяемы: подключение не проходит через конверт.

PreMCPConnectionHook(ctx, req)

Выполняется при установлении транспорта настроенного MCP-клиента. Используйте, чтобы:

  • добавить транспортные заголовки (клиенты HTTP/SSE);
  • изменить StdioCommand / StdioArgs для транспорта stdio;
  • отказать в подключении (досрочное завершение с ошибкой) по имени клиента или типу аутентификации.

Особенности:

  • Изменяемые поля (изменения учитываются): ConnectionString, Headers, StdioCommand, StdioArgs.
  • Только для чтения (изменения игнорируются): ClientName, ConnectionType, AuthType — их изменение на лету нарушило бы остальную логику подключения.
  • Изменения Headers отбрасываются для транспортов STDIO и InProcess — проверяйте req.ConnectionType.
  • (req, nil, nil) — продолжить с запросом (возможно, изменённым).
  • (req, &MCPConnectionShortCircuit{Error: ...}, nil) — отказать в подключении.
  • (req, &MCPConnectionShortCircuit{Response: ...}, nil) — синтезировать успешное рукопожатие (редко; в основном для тестов).
func PreMCPConnectionHook(ctx *schemas.BifrostContext, req *schemas.BifrostMCPConnectRequest) (*schemas.BifrostMCPConnectRequest, *schemas.MCPConnectionShortCircuit, error) {
	ctx.Log(schemas.LogLevelInfo, fmt.Sprintf("Connecting MCP client: %s (type=%s, auth=%s)", req.ClientName, req.ConnectionType, req.AuthType))

	// Отказ клиентам из чёрного списка с понятным 403
	if isBlocked(req.ClientName) {
		return req, &schemas.MCPConnectionShortCircuit{
			Error: &schemas.BifrostError{
				StatusCode: schemas.Ptr(403),
				Error: &schemas.ErrorField{
					Message: fmt.Sprintf("client %q is not permitted", req.ClientName),
				},
			},
		}, nil
	}

	// Аудиторский заголовок — только для HTTP/SSE; stdio и in-process игнорируют Headers
	if req.ConnectionType == schemas.MCPConnectionTypeHTTP || req.ConnectionType == schemas.MCPConnectionTypeSSE {
		if req.Headers == nil {
			req.Headers = make(map[string]string)
		}
		req.Headers["X-My-Audit"] = "meridian-connect:" + req.ClientName
	}

	return req, nil, nil
}

PostMCPConnectionHook(ctx, resp, bifrostErr)

Выполняется после рукопожатия initialize с сервером. Используйте, чтобы:

  • прочитать ServerInfo, ProtocolVersion и ServerCapabilities из согласованного рукопожатия;
  • принять решение по заявленным возможностям (например, предупредить, если сервер не объявляет Tools);
  • преобразовать ошибку рукопожатия до её возврата вызывающей стороне.

Особенности:

  • при сбое рукопожатия resp равен nil, а bifrostErr заполнен;
  • изменять resp можно, но редко полезно — результат рукопожатия уже закэширован конвейером подключения;
  • верните значения без изменений, чтобы пропустить их дальше; верните изменённый bifrostErr, чтобы преобразовать ошибку.
func PostMCPConnectionHook(ctx *schemas.BifrostContext, resp *schemas.BifrostMCPConnectResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostMCPConnectResponse, *schemas.BifrostError, error) {
	if bifrostErr != nil {
		// bifrostErr.Error — *ErrorField, может быть nil для некоторых сбоев
		// (например, сетевых ошибок рукопожатия). Проверяйте перед разыменованием.
		if bifrostErr.Error != nil {
			ctx.Log(schemas.LogLevelError, fmt.Sprintf("MCP handshake failed: %s", bifrostErr.Error.Message))
		}
		return resp, bifrostErr, nil
	}
	if resp != nil && resp.ServerInfo != nil {
		ctx.Log(schemas.LogLevelInfo, fmt.Sprintf("connected to %s v%s (protocol %s)", resp.ServerInfo.Name, resp.ServerInfo.Version, resp.ProtocolVersion))
	}
	if resp != nil && resp.ServerCapabilities != nil && !resp.ServerCapabilities.Tools {
		ctx.Log(schemas.LogLevelWarn, fmt.Sprintf("server %q does not advertise Tools capability", resp.ExtraFields.ClientName))
	}
	return resp, bifrostErr, nil
}

PreMCPHook(ctx, req)

Выполняется перед каждым запросом-конвертом MCP (ping / list_tools / execute_tool). Используйте, чтобы:

  • применять списки разрешённых инструментов и правила governance;
  • кэшировать список инструментов (досрочное завершение list_tools сохранённым значением);
  • сохранять метаданные запроса в контексте для PostMCPHook.

Особенности:

  • конверт различается по req.RequestType — выходите сразу для всех типов, кроме выполнения инструмента, если не хотите наблюдать ping и list_tools; для этого есть req.RequestType.IsExecuteTool();
  • для запросов выполнения инструмента используйте req.GetToolName() — метод возвращает имя инструмента независимо от того, какая форма подзапроса заполнена;
  • (req, &MCPPluginShortCircuit{Error: ...}, nil) — заблокировать вызов;
  • (req, &MCPPluginShortCircuit{Response: ...}, nil) — завершить досрочно синтетическим результатом.
func PreMCPHook(ctx *schemas.BifrostContext, req *schemas.BifrostMCPRequest) (*schemas.BifrostMCPRequest, *schemas.MCPPluginShortCircuit, error) {
	// Проверять только выполнение инструментов; ping и list_tools пропускать
	if !req.RequestType.IsExecuteTool() {
		return req, nil, nil
	}

	toolName := req.GetToolName()
	if isBlocked(toolName) {
		return req, &schemas.MCPPluginShortCircuit{
			Error: &schemas.BifrostError{
				StatusCode: schemas.Ptr(403),
				Error: &schemas.ErrorField{
					Message: fmt.Sprintf("tool %q is not allowed", toolName),
				},
			},
		}, nil
	}
	return req, nil, nil
}

PostMCPHook(ctx, resp, bifrostErr)

Выполняется после завершения каждого запроса-конверта MCP (успешного или с ошибкой). Используйте, чтобы:

  • фиксировать задержку и метрики вызовов инструментов;
  • преобразовывать ошибки;
  • дополнять аудиторские записи, начатые в PreMCPHook, через контекст.

Особенности:

  • как и PostLLMHook, выполняется в обратном порядке относительно pre-хуков по цепочке плагинов;
  • тип исходного запроса доступен как resp.ExtraFields.MCPRequestType (при ошибке — bifrostErr.ExtraFields.MCPRequestType), так что приём с IsExecuteTool() работает и здесь;
  • задержка в миллисекундах — resp.ExtraFields.Latency.
func PostMCPHook(ctx *schemas.BifrostContext, resp *schemas.BifrostMCPResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostMCPResponse, *schemas.BifrostError, error) {
	if resp != nil && resp.ExtraFields.MCPRequestType.IsExecuteTool() {
		ctx.Log(schemas.LogLevelInfo, fmt.Sprintf("tool %s completed in %dms", resp.ExtraFields.ToolName, resp.ExtraFields.Latency))
	}
	return resp, bifrostErr, nil
}

Полный рабочий пример со всеми четырьмя MCP-хуками (конверт и подключение) — examples/plugins/mcp-only. Берите его за основу, а не набирайте каркас заново.

Рецепт: подстановка динамических заголовков на стороне сервера

Проброс заголовков покрывает случай, когда значение заголовка передаёт вызывающая сторона. Иногда значение нужно вычислить на стороне сервера:

  • заголовок идентификации, который клиент не должен контролировать, например e-mail аутентифицированного пользователя для авторизации на стороне MCP-сервера;
  • динамическое значение на общем MCP-соединении, например короткоживущий сервисный токен или идентификатор корреляции запроса, которые нельзя выразить статическим полем headers, фиксированным при настройке.

В обоих случаях запишите заголовок в BifrostContextKeyMCPExtraHeaders из PreMCPHook. Meridian перечитывает этот ключ при каждом исходящем вызове по общему соединению, поэтому один хук покрывает ping, list_tools и execute_tool, а обновлённые значения действуют со следующего вызова без переподключения. Пример ниже подставляет e-mail вошедшего пользователя; любое значение, вычисленное в хуке (токен из хранилища секретов, идентификатор арендатора по виртуальному ключу), подставляется так же.

const (
	userEmailHeader = "X-User-Email"
	targetClient    = "acme_api"
)

// Без раннего выхода по IsExecuteTool(): заголовок должен сопровождать
// и ping, и обновление списка инструментов, а не только вызовы инструментов.
func PreMCPHook(ctx *schemas.BifrostContext, req *schemas.BifrostMCPRequest) (*schemas.BifrostMCPRequest, *schemas.MCPPluginShortCircuit, error) {
	// Подстановка только для клиента, которому предназначена идентификация.
	// Карта extra-headers питает каждый исходящий MCP-вызов этого запроса,
	// и без этой проверки любой клиент, чей allowed_extra_headers разрешает
	// заголовок (в том числе "*"), получил бы e-mail пользователя.
	if req.ClientName != targetClient {
		return req, nil, nil
	}

	// Заполняется middleware аутентификации, если запрос несёт вошедшего
	// пользователя; иначе пусто (например, неаутентифицированная проверка здоровья).
	email, _ := ctx.Value(schemas.BifrostContextKeyUserEmail).(string)
	if email == "" {
		return req, nil, nil
	}

	// Объединять, а не перезаписывать: транспортный слой или более ранний плагин
	// уже могли заполнить карту заголовками, пробрасываемыми от клиента.
	merged := make(map[string][]string)
	if existing, ok := ctx.Value(schemas.BifrostContextKeyMCPExtraHeaders).(map[string][]string); ok {
		for k, v := range existing {
			merged[k] = v
		}
	}
	merged[userEmailHeader] = []string{email}
	ctx.SetValue(schemas.BifrostContextKeyMCPExtraHeaders, merged)

	return req, nil, nil
}

BifrostContextKeyUserEmail заполняется только в Enterprise-развёртываниях с включённым provisioning пользователей; в остальных случаях значение всегда пустое, и именно этот плагин ничего не делает. Считайте заголовок с e-mail лишь одним примером приёма: так можно подставить любой заголовок. Входящие заголовки запроса тоже доступны в контексте под ключом BifrostContextKeyRequestHeaders (map[string]string с именами в нижнем регистре), так что значение, присланное клиентом, можно преобразовать и подставить заново.

Особенности:

  • Список разрешённых заголовков клиента по-прежнему действует. При выполнении каждый заголовок из BifrostContextKeyMCPExtraHeaders (подставленный плагином или пробрасываемый от клиента) проверяется по allowed_extra_headers MCP-клиента. Добавьте X-User-Email (или "*") каждому клиенту, который должен его получать, иначе заголовок молча отбрасывается. Этот список ограничивает доставку, а не приватность: клиент с "*" принимает любой подставленный заголовок, поэтому ограничивайте чувствительные заголовки нужным адресатом в самом хуке (проверка req.ClientName выше).
  • Только HTTP и SSE. Транспорты STDIO и in-process не несут HTTP-заголовков, для них подстановка ничего не делает.
  • Хуки подключения для этого не подходят: подключение выполняется один раз на общий транспорт, до появления идентичности конкретного запроса. Идентичность запроса относится к хукам конверта.
  • Остальные MCP-хуки экспортировать не нужно: загрузчик считает каждый из них необязательным.

Cleanup() error

Вызывается при остановке Meridian. Используйте, чтобы:

  • закрыть подключения к базам данных;
  • сбросить буферы;
  • сохранить состояние;
  • освободить ресурсы.

Шаг 3. Makefile

Создайте Makefile для автоматизации сборки:

.PHONY: all build clean help

PLUGIN_NAME = my-plugin
OUTPUT_DIR = build

# Определение платформы
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Linux)
	PLUGIN_EXT = .so
	PLATFORM = linux
endif
ifeq ($(UNAME_S),Darwin)
	PLUGIN_EXT = .so
	PLATFORM = darwin
	# macOS 15.4+: динамический загрузчик отвергает плагин Go со связанными fixup'ами
	PLUGIN_LDFLAGS = -ldflags='-extldflags=-Wl,-no_fixup_chains'
endif

# Определение архитектуры
UNAME_M := $(shell uname -m)
ifeq ($(UNAME_M),x86_64)
	ARCH = amd64
endif
ifeq ($(UNAME_M),arm64)
	ARCH = arm64
endif

OUTPUT = $(OUTPUT_DIR)/$(PLUGIN_NAME)$(PLUGIN_EXT)

build: ## Сборка плагина для текущей платформы
	@echo "Building plugin for $(PLATFORM)/$(ARCH)..."
	@mkdir -p $(OUTPUT_DIR)
	CGO_ENABLED=1 go build -buildmode=plugin $(PLUGIN_LDFLAGS) -o $(OUTPUT) .
	@echo "Plugin built successfully: $(OUTPUT)"

clean: ## Удаление артефактов сборки
	@rm -rf $(OUTPUT_DIR)

Шаг 4. Сборка плагина

Соберите плагин через Makefile:

make build

В каталоге проекта появится build/my-plugin.so.

Флаги сборки должны совпадать с флагами сборки Meridian: если шлюз собран с -trimpath, плагин собирается с -trimpath тоже; иначе загрузка завершится ошибкой несовпадения версий пакетов.

Кросс-компиляция плагинов не работает. Собирайте на целевой платформе. Плагин для Linux собирается на Linux — на машине или в контейнере. Плагин для контейнерного образа Meridian должен быть собран в той же среде, что и образ: тот же образ инструментария Go, та же библиотека C (musl в образах на Alpine), те же версии зависимостей. Плагин с рабочей станции в образ не загрузится.

Шаг 5. Подключение плагина к Meridian

Плагин регистрируется на странице Settings → Plugins, через API или в config.json. Файл .so должен быть доступен процессу Meridian по абсолютному пути или по URL.

  1. Перейдите в Settings → Plugins.
  2. Нажмите Install in-proc plugin.
Форма Install New Plugin: имя, путь или URL к .so и конфигурация
  1. Укажите Plugin Name и Plugin Path/URL — абсолютный путь к файлу .so или HTTP-URL, по которому Meridian его скачает.
  2. При необходимости добавьте конфигурацию через Add Configuration — она передаётся в Init().
  3. Нажмите Install Plugin. Плагин загружается сразу; статус и логи видны в деталях плагина.

Изменять, включать, выключать и удалять плагины может только глобальный администратор; просматривать список и детали — любой пользователь.

curl -X POST http://localhost:8080/api/plugins \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-plugin",
    "enabled": true,
    "path": "/opt/meridian/plugins/my-plugin.so",
    "placement": "post_builtin",
    "config": {
      "api_key": "your-api-key",
      "custom_setting": "value"
    }
  }'

Ответ:

{
  "message": "Plugin created successfully",
  "plugin": {
    "name": "my-plugin",
    "enabled": true,
    "path": "/opt/meridian/plugins/my-plugin.so",
    "placement": "post_builtin",
    "isCustom": true,
    "status": { "name": "my-plugin", "status": "active", "types": ["llm", "mcp", "http"] }
  }
}

Другие операции: GET /api/plugins — список, GET /api/plugins/{name} — детали и статус, PUT /api/plugins/{name} — изменение (в том числе enabled, config, path), DELETE /api/plugins/{name} — удаление.

{
  "plugins": [
    {
      "enabled": true,
      "name": "my-plugin",
      "path": "/opt/meridian/plugins/my-plugin.so",
      "version": 1,
      "placement": "post_builtin",
      "order": 0,
      "config": {
        "api_key": "your-api-key",
        "custom_setting": "value"
      }
    }
  ]
}
ПолеТипОбязательноеОписание
enabledbooleanДаtrue — загружать плагин
namestringДаИдентификатор плагина (используется в логах и API)
pathstringДля собственных плагиновАбсолютный путь или URL файла .so
configobjectНетКонфигурация плагина, передаётся в Init()
versionintegerНетВерсия записи (по умолчанию 1). Увеличьте значение, чтобы при следующем запуске принудительно перезагрузить плагин и обновить его запись в базе, например после изменения config
placementstringНетpre_builtin — до встроенных плагинов, post_builtin (по умолчанию) — после них
orderintegerНетПозиция внутри группы placement; меньшее значение выполняется раньше (по умолчанию 0)

Шаг 6. Проверка плагина

Запустите Meridian и убедитесь, что плагин загрузился:

./meridian -app-dir ./data -log-level info

В логе появится запись о загрузке плагина, а на странице Settings → Plugins он получит статус active.

Выполните тестовый запрос:

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <virtual key>" \
  -d '{
    "model": "openai/gpt-4o-mini",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'

Записи ctx.Log отображаются в деталях запроса (LLM Logs → запрос → Plugin Logs), сгруппированные по имени плагина:

HTTPTransportPreHook called
PreLLMHook called
PostLLMHook called
HTTPTransportPostHook called
HTTPTransportPreHook called
PreLLMHook called
PostLLMHook called
HTTPTransportStreamChunkHook called (для каждого фрагмента)

Продвинутые приёмы

Плагин с состоянием

Для плагинов, которым нужно хранить состояние между запросами:

package main

import (
	"fmt"
	"sync"

	"github.com/neria-cloud/meridian-base/core/schemas"
)

var (
	requestCount int64
	mu           sync.Mutex
)

func PreLLMHook(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.LLMPluginShortCircuit, error) {
	mu.Lock()
	requestCount++
	count := requestCount
	mu.Unlock()

	ctx.Log(schemas.LogLevelInfo, fmt.Sprintf("request count: %d", count))
	// Значение можно использовать для лимитов, метрик и т. п.
	return req, nil, nil
}

Управление fallback'ами при ошибках

Плагин решает, пробовать ли Meridian резервных провайдеров:

func PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) {
	if bifrostErr != nil {
		// При превышении лимита запросов — разрешить fallback
		if bifrostErr.Error != nil && bifrostErr.Error.Type != nil && *bifrostErr.Error.Type == "rate_limit" {
			ctx.Log(schemas.LogLevelWarn, "rate limit hit, allowing fallbacks")
			allowFallbacks := true
			bifrostErr.AllowFallbacks = &allowFallbacks
		} else {
			// При ошибке аутентификации — запретить fallback
			ctx.Log(schemas.LogLevelError, "auth error, blocking fallbacks")
			allowFallbacks := false
			bifrostErr.AllowFallbacks = &allowFallbacks
		}
	}
	return resp, bifrostErr, nil
}

Пример кэширующего плагина

var cache sync.Map

func PreLLMHook(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.LLMPluginShortCircuit, error) {
	// Ключ кэша по запросу
	key := generateCacheKey(req)

	// Проверка кэша
	if cached, ok := cache.Load(key); ok {
		ctx.Log(schemas.LogLevelDebug, "cache hit")
		return req, &schemas.LLMPluginShortCircuit{
			Response: cached.(*schemas.BifrostResponse),
		}, nil
	}

	ctx.Log(schemas.LogLevelDebug, "cache miss")
	return req, nil, nil
}

func PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) {
	if resp != nil && bifrostErr == nil {
		// Сохранение в кэш
		key := generateCacheKeyFromResponse(resp)
		cache.Store(key, resp)
		ctx.Log(schemas.LogLevelDebug, "response cached")
	}
	return resp, bifrostErr, nil
}

Диагностика

Плагин не загружается

Ошибка: plugin: not a plugin file

Решение: соберите плагин с флагом -buildmode=plugin:

go build -buildmode=plugin -o plugin.so .

Несовпадение версий

Ошибка: plugin was built with a different version of package

Причина. Механизм плагинов Go требует точного совпадения версий:

  • компилятора Go;
  • всех общих пакетов, в первую очередь github.com/neria-cloud/meridian-base/core;
  • транзитивных зависимостей — пакетов, от которых зависят ваши зависимости;
  • флагов компиляции, влияющих на пакеты (например, -trimpath).

Это строже обычной сборки Go: расхождение хотя бы одной транзитивной зависимости на патч-версию делает плагин незагружаемым.

Решение. Соберите плагин с теми же версиями, что и Meridian.

Шаг 1. Диагностика расхождения

Сравните сведения о сборке плагина и бинарного файла Meridian командой go version -m:

# Версии, с которыми собран плагин
$ go version -m my-plugin.so
my-plugin.so: go1.26.5
  dep  github.com/neria-cloud/meridian-base/core  v1.7.10
  dep  github.com/valyala/fasthttp                v1.67.0

# Версии, с которыми собран Meridian
$ go version -m meridian
meridian: go1.26.5
  dep  github.com/neria-cloud/meridian-base/core  v1.7.13   # <-- расхождение
  dep  github.com/valyala/fasthttp                v1.71.0   # <-- расхождение

Версия Go совпадает, но версии пакетов различаются — это и вызывает ошибку.

Шаг 2. Обновление зависимостей плагина

# Привести версию core к версии Meridian
go get github.com/neria-cloud/meridian-base/core@v1.7.13
go mod tidy

# Пересобрать плагин
go build -buildmode=plugin -o my-plugin.so .

Шаг 3. Проверка

$ go version -m my-plugin.so | grep meridian-base
  dep  github.com/neria-cloud/meridian-base/core  v1.7.13  # совпадает

Закрепляйте точные версии в go.mod и синхронизируйте зависимости плагина с версией Meridian, которую разворачиваете. Надёжнее всего собирать плагины в том же конвейере и той же среде, что и сам Meridian.

Несовпадение платформы или архитектуры

Ошибка: cannot load plugin built for GOOS=linux on darwin

Решение: собирайте на целевой платформе с правильными GOOS и GOARCH.

macOS: ошибка динамического загрузчика

Ошибка: chained fixups, seg_count does not match number of segments

Решение: на macOS 15.4 и новее соберите плагин с флагом линковщика -ldflags='-extldflags=-Wl,-no_fixup_chains' (см. Makefile в шаге 3).

Функция не найдена

Ошибка: plugin: symbol GetName not found

Решение: убедитесь, что функции экспортированы (имя начинается с заглавной буквы) и имеют правильные сигнатуры. Обязательны только GetName и Cleanup; все остальные хуки, включая четыре MCP-хука, загрузчик считает необязательными. Опечатка в имени хука не приводит к ошибке загрузки, а молча отключает этот хук.

Примеры

Полные примеры плагинов — в репозитории neria-cloud/meridian:

  • hello-world — все LLM- и HTTP-хуки, Makefile и go.mod.
  • llm-only — только PreLLMHook и PostLLMHook.
  • http-transport-only — только транспортные хуки.
  • mcp-only — четыре MCP-хука.
  • multi-interface — плагин, реализующий несколько интерфейсов сразу.

Встроенные плагины Meridian — журналирование, governance, семантический кэш, телеметрия — построены на тех же хуках.

Часто задаваемые вопросы

Нужно ли пересобирать плагин при обновлении Meridian?

Да, обязательно. Плагин должен быть собран с той же версией github.com/neria-cloud/meridian-base/core и тех же зависимостей, что и Meridian. Это фундаментальное требование механизма плагинов Go.

При обновлении Meridian:

  1. обновите в go.mod плагина версию core до соответствующей;
  2. пересоберите плагин той же версией Go;
  3. разверните плагин вместе с новой версией Meridian.
go get github.com/neria-cloud/meridian-base/core@<версия>
go mod tidy
go build -buildmode=plugin -o my-plugin.so .

Несовпадение версий приводит к ошибке во время выполнения. Плагин, собранный с другой версией core, не загрузится, а сообщение об ошибке будет говорить о версиях пакетов.

Должна ли сборка плагинов быть частью конвейера развёртывания?

Да, настоятельно рекомендуется. Сборка и развёртывание плагинов должны быть жёстко связаны с развёртыванием Meridian.

Ключевые принципы:

  1. Фиксация версий — закрепляйте зависимости плагина на конкретной версии Meridian.
  2. Атомарное развёртывание — разворачивайте Meridian и плагины вместе, как единое целое.
  3. Проверка сборки — проверяйте загрузку плагина в CI.
  4. Стратегия отката — храните предыдущие версии плагинов для отката.

Как организовать версионирование плагинов в production?

Раскладывайте развёртывания по версиям:

/opt/meridian/
├── v1.7.13/
│   ├── meridian
│   └── plugins/
│       ├── my-plugin.so
│       └── cache-plugin.so
├── v1.7.10/
│   ├── meridian
│   └── plugins/
│       ├── my-plugin.so
│       └── cache-plugin.so
└── current -> v1.7.13/   # символическая ссылка на активную версию

Откат сводится к переключению ссылки:

ln -sfn /opt/meridian/v1.7.10 /opt/meridian/current
systemctl restart meridian

Можно ли использовать одну сборку плагина для разных версий Meridian?

Нет. Плагин должен точно соответствовать версии core того экземпляра, который его загружает. Для нескольких версий Meridian (например, staging и production) нужны отдельные сборки плагина.

staging/
  meridian (v1.7.13)
  plugins/
    my-plugin-v1.7.13.so

production/
  meridian (v1.7.10)
  plugins/
    my-plugin-v1.7.10.so

Что будет, если забыть пересобрать плагин?

Появится ошибка вида:

plugin was built with a different version of package github.com/neria-cloud/meridian-base/core

Решение: пересоберите плагин с правильной версией core. Пошаговая диагностика с go version -m — в разделе Несовпадение версий.

Как тестировать плагин перед развёртыванием в production?

Многоступенчатое тестирование:

  1. Модульные тесты — логика плагина в изоляции:

    func TestPreHook(t *testing.T) {
        req := &schemas.BifrostRequest{ /* ... */ }
        modifiedReq, shortCircuit, err := PreLLMHook(ctx, req)
        assert.NoError(t, err)
        assert.Nil(t, shortCircuit)
        _ = modifiedReq
    }
  2. Интеграционные тесты — загрузка плагина в тестовый экземпляр Meridian:

    # Запуск тестового Meridian с плагином
    ./meridian -app-dir ./test-data
    
    # Тестовые запросы
    curl -X POST http://localhost:8080/v1/chat/completions ...
  3. Staging — развёртывание в среде, близкой к production по нагрузке.

  4. Канареечное развёртывание — постепенный вывод в production.

Можно ли перезагрузить плагин без перезапуска Meridian?

Да. Плагин можно включить, выключить или изменить его конфигурацию и путь на странице Settings → Plugins или через PUT /api/plugins/{name} без перезапуска. Учтите ограничение Go: код уже загруженной библиотеки остаётся в памяти процесса до его перезапуска, поэтому новая сборка того же плагина применяется после перезапуска Meridian.

Как отлаживать проблемы загрузки плагина?

Включите подробный лог: запустите Meridian с флагом -log-level debug (в контейнере — переменная окружения LOG_LEVEL=debug). Причина сбоя загрузки записывается в лог Meridian; ответ API содержит только общее сообщение об ошибке.

Проверьте экспортируемые символы:

go tool nm my-plugin.so | grep -E 'Init|GetName|PreLLMHook|PreMCPHook|PreMCPConnectionHook|PostMCPHook|PostMCPConnectionHook'

Проверьте версию Go и зависимостей:

go version -m my-plugin.so

Типовые шаги:

  1. Убедитесь, что файл существует и доступен процессу Meridian.
  2. Проверьте совпадение версии Go с Meridian.
  3. Проверьте совпадение версий core и остальных общих зависимостей.
  4. Убедитесь, что GetName и Cleanup экспортированы.
  5. Изучите лог Meridian — в нём подробное сообщение об ошибке.

Что дальше

Содержание

ОбзорПредварительные требованияСтруктура проектаШаг 1. Инициализация проектаШаг 2. Реализация интерфейса плагинаОписание функцийInit(config any) errorGetName() stringctx.Log(level, msg)ctx.GetModelInfo(provider, model)ctx.CalculateCost(resp)HTTPTransportPreHook(ctx, req)HTTPTransportPostHook(ctx, req, resp)HTTPTransportStreamChunkHook(ctx, req, chunk)PreRequestHook(ctx, req)PreLLMHook(ctx, req)PostLLMHook(ctx, resp, bifrostErr)Обзор MCP-хуковPreMCPConnectionHook(ctx, req)PostMCPConnectionHook(ctx, resp, bifrostErr)PreMCPHook(ctx, req)PostMCPHook(ctx, resp, bifrostErr)Рецепт: подстановка динамических заголовков на стороне сервераCleanup() errorШаг 3. MakefileШаг 4. Сборка плагинаШаг 5. Подключение плагина к MeridianШаг 6. Проверка плагинаПродвинутые приёмыПлагин с состояниемУправление fallback'ами при ошибкахПример кэширующего плагинаДиагностикаПлагин не загружаетсяНесовпадение версийНесовпадение платформы или архитектурыmacOS: ошибка динамического загрузчикаФункция не найденаПримерыЧасто задаваемые вопросыНужно ли пересобирать плагин при обновлении Meridian?Должна ли сборка плагинов быть частью конвейера развёртывания?Как организовать версионирование плагинов в production?Можно ли использовать одну сборку плагина для разных версий Meridian?Что будет, если забыть пересобрать плагин?Как тестировать плагин перед развёртыванием в production?Можно ли перезагрузить плагин без перезапуска Meridian?Как отлаживать проблемы загрузки плагина?Что дальше