Как создать плагин маршрутизации

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

Задача

Этот пример плагина меняет провайдера и модель запроса, глядя на его содержимое. Условие и цель взяты произвольно, чтобы результат было легко проверить. Если в сообщениях пользователя есть слово distributed, brainstorm или planning, запрос уходит на провайдера openrouter и модель deepseek/deepseek-v4-flash. Все остальные запросы остаются на той модели, которую указал клиент. Клиенты ничего не замечают: они шлют запросы как обычно, а решение принимает шлюз.

Тот же приём работает для любого правила, которое не выразить атрибутами запроса: тема разговора, язык сообщения, признак из внешней системы. Готовый пример на Go лежит в http-router-go, а ниже разобрано, почему он устроен именно так.

Встроенные правила маршрутизации Meridian выбирают провайдера и модель по атрибутам запроса: виртуальному ключу, заголовкам, модели, типу запроса. Плагин нужен, когда решение зависит от содержимого запроса или от данных снаружи.

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

PreRequestHook

provider: openrouter
model: deepseek-v4-flash

Клиент
model: LMStudio/qwen

Meridian

Плагин router

Governance:
ключ, лимиты, бюджет

OpenRouter

Весь дизайн плагина держится на трёх решениях.

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

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

Детали запроса: провайдер openrouter, модель deepseek/deepseek-v4-flash, в Plugin Logs строка router — PreRequestHook: request changed

У второго запроса строки router нет: плагин вызывался, но ничего не поменял.

Что можно улучшить

  • Правила без перезапуска. init получает конфигурацию при загрузке, а изменение записи плагина через PUT /api/plugins/router загружает его заново. Правила можно менять, не трогая процесс.
  • Видимость решений. Meridian пишет в Plugin Logs факт изменения запроса; какое правило сработало и куда ушёл запрос, плагин показывает на GET /router/stats и в своём логе.
  • Fallback'и. Так же, как провайдера и модель, PreRequestHook может задать список fallbacks, чтобы при отказе целевого провайдера запрос вернулся на исходную модель.
  • Не только слова. Вместо регулярных выражений решение может принимать классификатор или отдельная модель. Ограничение одно: уложиться в таймаут вызова hook, иначе сработает политика on_error.

Что дальше

Содержание