Разработка 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:
| Поле | Тип | Описание |
|---|---|---|
ID | string | Запрошенная строка модели |
Pricing | *schemas.Pricing | Базовые ставки за токен в виде десятичных строк: Prompt, Completion, Image, InputCacheRead, InputCacheWrite, WebSearch |
ContextLength | *int | Размер контекстного окна; при отсутствии отдельного значения в каталоге — максимум входных токенов |
MaxInputTokens | *int | Предел входных токенов |
MaxOutputTokens | *int | Предел выходных токенов |
Architecture | *schemas.Architecture | Модальность, токенизатор, входные и выходные модальности |
SupportedParameters | []string | Параметры запроса, которые принимает модель |
IsDeprecated | bool | Признак выведенной из эксплуатации модели |
AdditionalAttributes | map[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:
| Аспект | PreRequestHook | PreLLMHook |
|---|---|---|
| Выполняется | Один раз на запрос | На каждую попытку обращения к провайдеру (повторно при каждом 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_headersMCP-клиента. Добавьте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.
- Перейдите в Settings → Plugins.
- Нажмите Install in-proc plugin.

- Укажите Plugin Name и Plugin Path/URL — абсолютный путь к файлу
.soили HTTP-URL, по которому Meridian его скачает. - При необходимости добавьте конфигурацию через Add Configuration — она передаётся в
Init(). - Нажмите 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"
}
}
]
}| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
enabled | boolean | Да | true — загружать плагин |
name | string | Да | Идентификатор плагина (используется в логах и API) |
path | string | Для собственных плагинов | Абсолютный путь или URL файла .so |
config | object | Нет | Конфигурация плагина, передаётся в Init() |
version | integer | Нет | Версия записи (по умолчанию 1). Увеличьте значение, чтобы при следующем запуске принудительно перезагрузить плагин и обновить его запись в базе, например после изменения config |
placement | string | Нет | pre_builtin — до встроенных плагинов, post_builtin (по умолчанию) — после них |
order | integer | Нет | Позиция внутри группы 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 calledHTTPTransportPreHook 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:
- обновите в
go.modплагина версию core до соответствующей; - пересоберите плагин той же версией Go;
- разверните плагин вместе с новой версией Meridian.
go get github.com/neria-cloud/meridian-base/core@<версия>
go mod tidy
go build -buildmode=plugin -o my-plugin.so .Несовпадение версий приводит к ошибке во время выполнения. Плагин, собранный с другой версией core, не загрузится, а сообщение об ошибке будет говорить о версиях пакетов.
Должна ли сборка плагинов быть частью конвейера развёртывания?
Да, настоятельно рекомендуется. Сборка и развёртывание плагинов должны быть жёстко связаны с развёртыванием Meridian.
Ключевые принципы:
- Фиксация версий — закрепляйте зависимости плагина на конкретной версии Meridian.
- Атомарное развёртывание — разворачивайте Meridian и плагины вместе, как единое целое.
- Проверка сборки — проверяйте загрузку плагина в CI.
- Стратегия отката — храните предыдущие версии плагинов для отката.
Как организовать версионирование плагинов в 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?
Многоступенчатое тестирование:
-
Модульные тесты — логика плагина в изоляции:
func TestPreHook(t *testing.T) { req := &schemas.BifrostRequest{ /* ... */ } modifiedReq, shortCircuit, err := PreLLMHook(ctx, req) assert.NoError(t, err) assert.Nil(t, shortCircuit) _ = modifiedReq } -
Интеграционные тесты — загрузка плагина в тестовый экземпляр Meridian:
# Запуск тестового Meridian с плагином ./meridian -app-dir ./test-data # Тестовые запросы curl -X POST http://localhost:8080/v1/chat/completions ... -
Staging — развёртывание в среде, близкой к production по нагрузке.
-
Канареечное развёртывание — постепенный вывод в 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Типовые шаги:
- Убедитесь, что файл существует и доступен процессу Meridian.
- Проверьте совпадение версии Go с Meridian.
- Проверьте совпадение версий core и остальных общих зависимостей.
- Убедитесь, что
GetNameиCleanupэкспортированы. - Изучите лог Meridian — в нём подробное сообщение об ошибке.
Что дальше
- Начало работы — виды плагинов, жизненный цикл и порядок хуков.
- Разработка HTTP-плагина — плагин в отдельном процессе на любом языке.
- Как собрать плагин маршрутизации — выбор провайдера и модели по содержимому запроса.
- Как собрать плагин guardrail — блокировка и маскирование конфиденциальных данных.