Как создать плагин guardrail

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

Задача

Этот пример плагина следит за телефонными номерами в чат-запросах. У него два режима. В режиме block запрос с номером отклоняется с кодом 400, и до модели он не доходит. В режиме mask каждый номер заменяется токеном, модель видит только токен, а в ответе плагин подставляет настоящие номера обратно, в том числе в потоковом ответе. Клиент получает осмысленный ответ, а провайдер номера не видит.

Телефонные номера взяты для наглядности: их легко найти регулярным выражением и легко проверить результат. Тот же приём подходит для e-mail, номеров карт, идентификаторов клиентов и любых данных, которые нельзя отправлять наружу. Готовый пример на Go лежит в http-guardrail-go, а ниже разобрано, как он устроен.

Как это работает

ПровайдерПлагин guardrailMeridianКлиентПровайдерПлагин guardrailMeridianКлиентalt[режим block][режим mask]запрос: «…my phone carrier: +1 555 444 0082»PreLLMHook (весь запрос)short_circuit 400 guardrail_violation400 request contains a phone numberзапрос с токеном <ph:11756e…>, body_changedзапрос с токеномответ с токеномPostLLMHook / StreamChunkHook на каждый фрагментответ с номером, body_changedответ с настоящим номером

Плагину нужны три 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. Плагин держит на каждый запрос небольшой буфер:

  1. К тексту фрагмента приписывается «хвост» с прошлого раза.
  2. В получившейся строке заменяются целые токены.
  3. Если строка заканчивается на что-то, что может быть началом токена (например, <ph:11), этот кусок откладывается в буфер и в этот фрагмент не попадает.
  4. Фрагмент с 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

  1. Откройте Settings → Plugins и нажмите Add HTTP plugin.
Форма Add HTTP Plugin: имя, URL, hooks, фильтр запросов и политика при ошибке транспорта
  1. Name — guardrail, URL — http://localhost:18082.
  2. Hooks — PreLLMHook, PostLLMHook, HTTPTransportStreamChunkHook.
  3. Request filter — request_type.startsWith("chat_completion").
  4. On transport error — Fail closed: если guardrail недоступен, запросы не должны уходить к провайдеру без проверки.
  5. Placement — After the built-in plugins: governance сначала проверит ключ и лимиты, и плагин не будет тратить время на запросы, которые всё равно отклонят.
  6. Нажмите 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 видно исходное сообщение и текст ошибки:

Детали заблокированного запроса: сообщение пользователя с номером и ошибка request contains a phone number

А в Plugin Logs под именем плагина стоит строка с уровнем WARN — Meridian записывает каждый short-circuit вместе с кодом, типом и сообщением:

Plugin Logs заблокированного запроса: PreLLMHook: short-circuit 400 guardrail_violation: request contains a phone number

Задержка запроса — около миллисекунды: до провайдера он не дошёл.

Шаг 8. Проверьте маскирование

Переключите плагин в режим mask и отправьте запрос с номером из любого чат-клиента, подключённого к Meridian:

Чат-клиент показывает ответ с настоящим номером, а в деталях запроса Meridian модель получила токен

Клиент задал вопрос про номер +15554440082 и получил ответ, в котором стоит этот же номер. Но модель его не видела. В деталях запроса на вкладке Messages вместо номера — токен <ph:11756e0edc05b026>, и по рассуждениям модели видно, что она пыталась понять, что это за строка:

Детали запроса: в сообщении пользователя и в ответе модели вместо номера стоит токен ph

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

Вкладка 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" вместо фильтра по типу запроса.

Что дальше

Содержание