Как создать плагин маршрутизации
HTTP-плагин, который направляет запрос к другому провайдеру и модели по содержимому сообщений пользователя.
Задача
Этот пример плагина меняет провайдера и модель запроса, глядя на его содержимое. Условие и цель взяты произвольно, чтобы результат было легко проверить. Если в сообщениях пользователя есть слово distributed, brainstorm или planning, запрос уходит на провайдера openrouter и модель deepseek/deepseek-v4-flash. Все остальные запросы остаются на той модели, которую указал клиент. Клиенты ничего не замечают: они шлют запросы как обычно, а решение принимает шлюз.
Тот же приём работает для любого правила, которое не выразить атрибутами запроса: тема разговора, язык сообщения, признак из внешней системы. Готовый пример на Go лежит в http-router-go, а ниже разобрано, почему он устроен именно так.
Встроенные правила маршрутизации Meridian выбирают провайдера и модель по атрибутам запроса: виртуальному ключу, заголовкам, модели, типу запроса. Плагин нужен, когда решение зависит от содержимого запроса или от данных снаружи.
Как это работает
Весь дизайн плагина держится на трёх решениях.
Hook — PreRequestHook. Meridian отводит этот hook под выбор провайдера, модели и fallback'ов. Он срабатывает один раз на запрос, ещё до обращения к провайдеру, и сразу после него Meridian заново читает провайдера и модель из запроса. Поменять провайдера можно и в PreLLMHook, но тот срабатывает на каждую попытку и уже после проверок governance.
Размещение — pre_builtin. Плагин работает раньше встроенных плагинов Meridian, поэтому governance видит уже изменённый запрос и проверяет права виртуального ключа, лимиты и бюджет для целевого провайдера и модели, а не для исходных.
Ответ — только два поля. Плагин возвращает {"provider": …, "model": …} и body_changed: true. Meridian накладывает этот JSON на свою структуру запроса, так что сообщения, параметры и всё остальное остаются как были, а по сети не ходит ничего лишнего.
Шаг 1. Объявите hook и конфигурацию
Плагин заявляет один hook:
{ "hooks": ["PreRequestHook"], "protocol_version": 1, "stream_supported": true }Правила приходят в init из поля config записи плагина. Если конфигурации нет, работает правило по умолчанию:
{
"rules": [
{ "words": ["distributed", "brainstorm", "planning"], "provider": "openrouter", "model": "deepseek/deepseek-v4-flash" }
]
}Слова собираются в одно регулярное выражение с границами слов и без учёта регистра, чтобы planning не ловило replanning. Правила проверяются по порядку, первое совпавшее побеждает:
type rule struct {
Words []string `json:"words"`
Provider string `json:"provider"`
Model string `json:"model"`
}
func compile(r rule) (*regexp.Regexp, error) {
quoted := make([]string, len(r.Words))
for i, w := range r.Words {
quoted[i] = regexp.QuoteMeta(strings.TrimSpace(w))
}
return regexp.Compile(`(?i)\b(` + strings.Join(quoted, "|") + `)\b`)
}Правило без слов, провайдера или модели init отбрасывает с ошибкой. Meridian такой плагин не загрузит, и причина будет видна в его статусе.
Шаг 2. Читайте из запроса только нужное
PreRequestHook приносит типизированный запрос в request.request. Для chat completion это provider, model, input с сообщениями и params. Плагину нужны только роли и содержимое сообщений, поэтому он разбирает ровно их, а содержимое оставляет сырым JSON:
type chatRequest struct {
Provider string `json:"provider"`
Model string `json:"model"`
Input []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
} `json:"input"`
}Содержимое сообщения бывает строкой или списком блоков вроде [{"type": "text", "text": "…"}]. Функция userText собирает текст только из сообщений с ролью user, а системный промпт и ответы ассистента пропускает. Иначе слово planning в системной инструкции переключало бы модель на каждом запросе.
Запросы других типов, например embedding или transcription, плагин пропускает как есть: проверка request_type на префикс chat_completion стоит первой.
Шаг 3. Верните решение
Если текст совпал с правилом, а запрос ещё не адресован нужному провайдеру и модели, плагин отвечает частичным запросом:
func (p *plugin) preRequestHook(payload []byte) (any, error) {
var req preRequestHookRequest
if err := json.Unmarshal(payload, &req); err != nil {
return nil, err
}
if !strings.HasPrefix(req.Request.RequestType, "chat_completion") {
return preRequestHookResponse{}, nil
}
var chat chatRequest
if err := json.Unmarshal(req.Request.Request, &chat); err != nil {
return nil, fmt.Errorf("request: %w", err)
}
text := userText(chat)
for _, r := range p.rules {
if !r.re.MatchString(text) {
continue
}
if chat.Provider == r.provider && chat.Model == r.model {
return preRequestHookResponse{}, nil // уже там, куда нужно
}
target, _ := json.Marshal(routeTarget{Provider: r.provider, Model: r.model})
return preRequestHookResponse{
Request: &llmRequest{RequestType: req.Request.RequestType, Request: target},
BodyChanged: true,
}, nil
}
return preRequestHookResponse{}, nil
}По сети уходит вот это:
{
"request": { "request_type": "chat_completion", "request": { "provider": "openrouter", "model": "deepseek/deepseek-v4-flash" } },
"body_changed": true
}Во всех остальных случаях ответ — {}. Meridian оставляет запрос как есть и ничего лишнего не разбирает.
Шаг 4. Запустите и проверьте плагин
make run ADDR=127.0.0.1:18083Решение можно проверить и без Meridian: POST-маршрут принимает то же сообщение, что и HookStream.
curl -s -X POST http://localhost:18083/api/meridian/plugin/v1/prerequesthook -d '{
"ctx": {"request_id": "r1"},
"request": {"request_type": "chat_completion",
"request": {"provider": "LMStudio", "model": "qwen/qwen3.8-27b",
"input": [{"role": "user", "content": "Let us brainstorm the launch"}]}}
}'В ответе будут openrouter, deepseek/deepseek-v4-flash и "body_changed":true. Тот же запрос с текстом Say hi вернёт {}. Счётчик срабатываний и последнее решение видны на GET /router/stats.
Шаг 5. Зарегистрируйте плагин в Meridian
- Откройте Settings → Plugins и нажмите Add HTTP plugin.

- Name —
router, URL —http://localhost:18083. - Hooks —
PreRequestHook. - Request filter —
request_type.startsWith("chat_completion"), чтобы плагин не дёргали на остальных запросах. - On transport error —
Fail open: если маршрутизатор недоступен, запросы идут на исходную модель. - Placement —
Before the built-in plugins. - Нажмите Add и проверьте, что у плагина статус
active, аPreRequestHookесть в списке эффективных hooks.
curl -X POST http://localhost:8080/api/plugins \
-H "Content-Type: application/json" \
-d '{
"plugin_architecture": "http",
"name": "router",
"enabled": true,
"url": "http://localhost:18083",
"capabilities": ["PreRequestHook"],
"cel": "request_type.startsWith(\"chat_completion\")",
"placement": "pre_builtin",
"on_error": "fail_open"
}'Свои правила передаются в config:
"config": { "rules": [ { "words": ["legal", "contract"], "provider": "anthropic", "model": "claude-opus-5" } ] }Виртуальный ключ, с которым пришёл запрос, должен разрешать целевого провайдера и модель, а у провайдера должен быть ключ. Иначе governance отклонит перенаправленный запрос с сообщением о неразрешённом провайдере. Это штатная защита, а не ошибка плагина.
Шаг 6. Проверьте маршрутизацию
Отправьте два запроса с одной и той же моделью:
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": "Let us brainstorm three names for a cat."}]}'
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": "Say hi"}]}'Первый ответ придёт от OpenRouter с моделью deepseek/deepseek-v4-flash, второй — от LMStudio. В деталях первого запроса (LLM Logs → запрос) провайдер и модель уже целевые, а в Plugin Logs под именем router стоит PreRequestHook: request changed:
У второго запроса строки router нет: плагин вызывался, но ничего не поменял.
Что можно улучшить
- Правила без перезапуска.
initполучает конфигурацию при загрузке, а изменение записи плагина черезPUT /api/plugins/routerзагружает его заново. Правила можно менять, не трогая процесс. - Видимость решений. Meridian пишет в Plugin Logs факт изменения запроса; какое правило сработало и куда ушёл запрос, плагин показывает на
GET /router/statsи в своём логе. - Fallback'и. Так же, как провайдера и модель,
PreRequestHookможет задать списокfallbacks, чтобы при отказе целевого провайдера запрос вернулся на исходную модель. - Не только слова. Вместо регулярных выражений решение может принимать классификатор или отдельная модель. Ограничение одно: уложиться в таймаут вызова hook, иначе сработает политика
on_error.
Что дальше
- Разработка HTTP-плагина — протокол, регистрация и диагностика HTTP-плагинов.
- Как собрать плагин guardrail — блокировка и маскирование данных в
PreLLMHook,PostLLMHookи потоковых ответах. - Маршрутизация — встроенные правила маршрутизации по атрибутам запроса.