Как создать плагин guardrail
HTTP-плагин, который не пропускает телефонные номера к модели: блокирует запрос или маскирует номера и возвращает их в ответе.
Задача
Этот пример плагина следит за телефонными номерами в чат-запросах. У него два режима. В режиме block запрос с номером отклоняется с кодом 400, и до модели он не доходит. В режиме mask каждый номер заменяется токеном, модель видит только токен, а в ответе плагин подставляет настоящие номера обратно, в том числе в потоковом ответе. Клиент получает осмысленный ответ, а провайдер номера не видит.
Телефонные номера взяты для наглядности: их легко найти регулярным выражением и легко проверить результат. Тот же приём подходит для e-mail, номеров карт, идентификаторов клиентов и любых данных, которые нельзя отправлять наружу. Готовый пример на Go лежит в http-guardrail-go, а ниже разобрано, как он устроен.
Как это работает
Плагину нужны три hooks и одно хранилище.
PreLLMHook — решение. Плагин получает типизированный запрос целиком: системный промпт, все сообщения, результаты инструментов. Он обходит все строки запроса и ищет в них номера. В режиме block первое совпадение даёт short_circuit с ошибкой 400. В режиме mask каждое совпадение заменяется токеном, а изменённый запрос возвращается с body_changed: true.
PostLLMHook и HTTPTransportStreamChunkHook — восстановление. В ответе модели токены заменяются обратно на номера. Для обычного ответа это один вызов PostLLMHook. Для потокового ответа Meridian вызывает HTTPTransportStreamChunkHook на каждый фрагмент, и здесь есть ловушка: токен может оказаться разрезан между двумя фрагментами.
Хранилище токенов. Пара «токен — номер» живёт в памяти плагина. В примере это обычная map без срока жизни; в реальном плагине здесь будет внешнее хранилище с TTL, чтобы номера не копились бесконечно и были доступны всем экземплярам плагина.
Почему именно PreLLMHook, а не транспортный HTTPTransportPreHook? Транспортный hook видит сырое тело HTTP-запроса в формате конкретного API, а PreLLMHook — уже разобранный запрос Meridian с одинаковой структурой для OpenAI-совместимых, Anthropic и других форматов. Один обход дерева покрывает все API.
Шаг 1. Объявите hooks и конфигурацию
{ "hooks": ["PreLLMHook", "PostLLMHook", "HTTPTransportStreamChunkHook"], "protocol_version": 1, "stream_supported": true }Конфигурация приходит в init. Все поля необязательны:
{
"mode": "mask",
"patterns": ["\\+?\\d[\\d\\s\\-().]{6,16}\\d"],
"forbidden_combination": ["(?i)wire transfer", "(?i)urgent"]
}mode—blockилиmask, по умолчаниюblock.patterns— регулярные выражения для поиска; по умолчанию одно, для телефонных номеров.forbidden_combination— набор выражений; если каждое из них встретилось где-то в запросе, запрос отклоняется в любом режиме. Так ловятся опасные сочетания, разбросанные по разным сообщениям: например, «wire transfer» в одном и «urgent» в другом.
Неверное регулярное выражение init отбрасывает с ошибкой, и Meridian не загружает плагин.
Шаг 2. Обойдите весь запрос
Плагин не знает заранее, где именно окажется номер: в системном промпте, в сообщении пользователя, в результате вызова инструмента. Поэтому он разбирает запрос в дерево map и []any и обходит все строковые значения одной функцией:
func walkStrings(node any, fn func(string) string) any {
switch x := node.(type) {
case string:
return fn(x)
case []any:
for i := range x {
x[i] = walkStrings(x[i], fn)
}
return x
case map[string]any:
for k, v := range x {
x[k] = walkStrings(v, fn)
}
return x
}
return node
}Такой обход не зависит от формата сообщений: строка content, список блоков, вложенные объекты инструментов обрабатываются одинаково.
Шаг 3. Заблокируйте или замаскируйте
Сердце PreLLMHook — проход по строкам с учётом режима:
found := false
tree = walkStrings(tree, func(s string) string {
for _, re := range patterns {
if mode == "block" {
if re.MatchString(s) {
found = true
}
continue
}
out, hit := p.vault.mask(re, s) // заменяет совпадения токенами и запоминает их
if hit {
found = true
s = out
}
}
return s
})
if !found {
return preLLMHookResponse{}, nil
}
if mode == "block" {
return preLLMHookResponse{ShortCircuit: guardrailError("request contains a phone number")}, nil
}
b, _ := sonic.Marshal(tree)
return preLLMHookResponse{Request: &llmRequest{RequestType: req.Request.RequestType, Request: b}, BodyChanged: true}, nilЕсли совпадений нет, ответ — {}, и Meridian ничего не разбирает. В режиме block по сети уходит отказ:
{
"short_circuit": {
"error": {
"status_code": 400,
"allow_fallbacks": false,
"error": { "type": "guardrail_violation", "message": "request contains a phone number" }
}
}
}allow_fallbacks: false важен: без него Meridian попробовал бы отправить тот же запрос резервному провайдеру. Флаг is_bifrost_error не ставится: это решение по запросу клиента, а не сбой шлюза, и Meridian обрабатывает его как обычный ответ 400.
В режиме mask уходит запрос с токенами и body_changed: true. Токен выглядит как <ph:11756e0edc05b026>: префикс, 16 шестнадцатеричных знаков и закрывающая скобка. Такой формат легко найти в ответе модели и трудно спутать с чем-то ещё.
Шаг 4. Верните номера в ответ
PostLLMHook получает ответ провайдера, обходит его строки той же функцией walkStrings и заменяет каждый токен на номер из хранилища. Изменённый ответ уходит с body_changed: true; если токенов не было, ответ снова {}.
С потоковым ответом сложнее. Модель отдаёт текст фрагментами по несколько символов, и токен <ph:11756e0edc05b026> запросто приходит в трёх фрагментах: call <ph:11, 756e0edc05b026>, now. Плагин держит на каждый запрос небольшой буфер:
- К тексту фрагмента приписывается «хвост» с прошлого раза.
- В получившейся строке заменяются целые токены.
- Если строка заканчивается на что-то, что может быть началом токена (например,
<ph:11), этот кусок откладывается в буфер и в этот фрагмент не попадает. - Фрагмент с
finish_reasonсбрасывает буфер целиком: дальше ничего не придёт.
Клиент видит текст с небольшой задержкой в несколько символов, только когда в потоке встречается <, и никогда не видит половину токена. Буферы хранятся по request_id из ctx, так что параллельные потоки друг другу не мешают.
HTTPTransportPostHook для потоковых ответов не вызывается, поэтому без HTTPTransportStreamChunkHook потоковый ответ уйдёт клиенту с токенами внутри. Объявляйте оба hooks восстановления.
Шаг 5. Запустите и проверьте плагин
make run MODE=mask ADDR=127.0.0.1:18082Проверьте маскирование без Meridian:
curl -s -X POST http://localhost:18082/api/meridian/plugin/v1/prellmhook -d '{
"ctx": {"request_id": "r1"},
"request": {"request_type": "chat_completion",
"request": {"provider": "openai", "model": "gpt-4o-mini",
"input": [{"role": "user", "content": "call me at +1 555 010 0100 please"}]}}
}'В ответе номер заменён токеном <ph:…>, и стоит "body_changed":true. В режиме block тот же вызов вернёт short_circuit с кодом 400. Счётчики заблокированных и замаскированных запросов и размер хранилища видны на GET /guardrail/stats.
Шаг 6. Зарегистрируйте плагин в Meridian
- Откройте Settings → Plugins и нажмите Add HTTP plugin.

- Name —
guardrail, URL —http://localhost:18082. - Hooks —
PreLLMHook,PostLLMHook,HTTPTransportStreamChunkHook. - Request filter —
request_type.startsWith("chat_completion"). - On transport error —
Fail closed: если guardrail недоступен, запросы не должны уходить к провайдеру без проверки. - Placement —
After the built-in plugins: governance сначала проверит ключ и лимиты, и плагин не будет тратить время на запросы, которые всё равно отклонят. - Нажмите Add и проверьте, что у плагина статус
active, а все три hooks есть в списке эффективных.
Режим работы задаётся конфигурацией плагина. Веб-интерфейс пока не показывает поле config для HTTP-плагинов, поэтому режим mask включается через API.
curl -X POST http://localhost:8080/api/plugins \
-H "Content-Type: application/json" \
-d '{
"plugin_architecture": "http",
"name": "guardrail",
"enabled": true,
"url": "http://localhost:18082",
"capabilities": ["PreLLMHook", "PostLLMHook", "HTTPTransportStreamChunkHook"],
"cel": "request_type.startsWith(\"chat_completion\")",
"on_error": "fail_closed",
"placement": "post_builtin",
"config": { "mode": "mask" }
}'Без config плагин работает в режиме block. Свои шаблоны и запрещённые сочетания передаются там же:
"config": { "mode": "block", "patterns": ["\\b\\d{16}\\b"], "forbidden_combination": ["(?i)wire transfer", "(?i)urgent"] }Режим fail_closed означает, что при недоступности плагина клиент получит 503 plugin guardrail unavailable. Для guardrail это правильное поведение: лучше отказать, чем пропустить данные наружу. Следите за здоровьем плагина в его деталях и держите его рядом со шлюзом, чтобы сетевые задержки не открывали circuit breaker.
Шаг 7. Проверьте блокировку
Запустите плагин в режиме block (или зарегистрируйте без config) и отправьте сообщение с номером:
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <virtual key>" \
-d '{"model": "LMStudio/qwen/qwen3.8-27b",
"messages": [{"role": "user", "content": "+7930 080 00 00 - phone me"}]}'Клиент получает 400 с телом guardrail_violation: request contains a phone number. В LLM Logs запрос помечен как ошибка, в его деталях в разделе Messages видно исходное сообщение и текст ошибки:
А в Plugin Logs под именем плагина стоит строка с уровнем WARN — Meridian записывает каждый short-circuit вместе с кодом, типом и сообщением:
Задержка запроса — около миллисекунды: до провайдера он не дошёл.
Шаг 8. Проверьте маскирование
Переключите плагин в режим mask и отправьте запрос с номером из любого чат-клиента, подключённого к Meridian:
Клиент задал вопрос про номер +15554440082 и получил ответ, в котором стоит этот же номер. Но модель его не видела. В деталях запроса на вкладке Messages вместо номера — токен <ph:11756e0edc05b026>, и по рассуждениям модели видно, что она пыталась понять, что это за строка:
Вкладка Raw JSON показывает тело запроса до обработки плагинами, с настоящим номером. По ней удобно сверять, что именно заменил плагин:
В Plugin Logs у такого запроса три строки: PreLLMHook: request changed, PostLLMHook: response changed и итог по потоку от HTTPTransportStreamChunkHook с числом изменённых фрагментов.
Логи запросов Meridian хранят то, что видела модель, то есть токены. Исходный номер остаётся только в теле запроса на вкладке Raw JSON, если включено хранение сырых запросов. Решите заранее, нужно ли оно вам при работающем guardrail.
Что можно улучшить
- Хранилище с TTL. Пара «токен — значение» нужна только до конца ответа. Внешнее хранилище со сроком жизни в несколько минут решает две задачи: память не растёт, и несколько экземпляров плагина за балансировщиком видят одни и те же токены.
- Больше типов данных. Добавьте в
patternsвыражения для e-mail, номеров карт и паспортов; токены можно снабдить префиксом типа, чтобы модель понимала, что перед ней замаскированный e-mail, а не телефон. - Проверка ответа.
PostLLMHookможет не только восстанавливать токены, но и проверять ответ модели по тем же правилам, заменяя его на ошибку черезplugin_error. - Выборочное включение. CEL-фильтр позволяет включить guardrail только для определённых клиентов или команд:
customer_name == "external"вместо фильтра по типу запроса.
Что дальше
- Разработка HTTP-плагина — протокол, регистрация и диагностика HTTP-плагинов.
- Как создать плагин маршрутизации — выбор провайдера и модели по содержимому запроса в
PreRequestHook. - Начало работы — виды плагинов, жизненный цикл и порядок hooks.