llm api для разработчиков: чиню router после переезда
Утром прилетело сообщение от друга: «Баратишка, мы переехали на новый сервер, и теперь данный проект у меня не работает нигде». Речь шла про llm-router - мой самодельный llm api для разработчиков, который проксирует оплаченные подписки (Claude Max, ChatGPT Plus, теперь ещё и Gemini) в проекты с ИИ-агентами. Он стоит между моими ботами и API-провайдерами и делает одну скучную вещь - превращает дорогие per-token вызовы в бесплатные (для меня) обращения к уже оплаченным подпискам.
Когда сервис умолк, я понял, что миграция KZ → Hetzner оставила за собой больше хвостов, чем казалось на первый взгляд. И вместо тривиальной починки получилась полноценная история про деплой, DNS, content negotiation и архитектурные инварианты, которые я зафиксировал в документации, чтобы не наступать на одни и те же грабли заново.
Что вообще делает llm-router
Если коротко — это control plane для подписочных LLM-доступов. Он получает запрос от какого-нибудь моего проекта (например, Telegram-бота или CRM-плагина), смотрит на проектный токен, выбирает нужный профиль (Claude Max через Anthropic OAuth, ChatGPT Plus через openai-oauth) и форвардит запрос в апстрим, подставляя нужные заголовки авторизации.
Ключевая идея: никаких pay-as-you-go API key. Только подписки. Это и есть та самая «задумка», ради которой проект писался. Если в моём бэкенде живут пять разных AI-фич, каждая из них обращается не напрямую к Anthropic API (где платишь за токен), а к llm-router, который под капотом дёргает мою подписку Claude Max за фиксированные деньги в месяц. На дистанции это десятки тысяч рублей экономии, особенно когда в проектах сидят автономные агенты с долгими циклами размышлений.
Этот инвариант — subscription-only — в итоге пришлось отдельно прописать в KNOWLEDGE.md, потому что и я сам, и Gemini, составляя ТЗ для нового транспорта, периодически забывали про него и предлагали добавить «MVP через api_key из AI Studio». Каждый раз приходилось останавливаться и переделывать.
Что произошло при переезде
Диагностика выявила картину, типичную для миграций «по-быстрому»:
- ✅ Код на месте —
/home/deploy/projects/llm-routerскопировался целиком - ✅ Auth-данные сохранены —
~/.claude/.credentials.json,~/.codex/auth.json,.llm-router/auth-profiles.json,project-tokens.json - ❌
node_modulesотсутствуют —npm installникто не запускал - ❌ Процесс на порту 3200 не запущен
- ❌ Никакого systemd unit / cron / docker-compose — механизма автозапуска просто нет
- ❌ В Caddyfile нет блока для домена
Проще говоря, файлы перевезли, но сервис не подняли. На старом сервере он, видимо, работал через ручной node index.js в tmux, и при копировании этот «способ запуска» естественным образом потерялся.
Я предложил сделать всё по-нормальному — поднять как штатный сервис под собственным поддоменом, под systemd, через Caddy с автоматическим TLS и Basic Auth поверх дашборда. Получил «ага, делай 1+2+3» — и поехали.
Решение: systemd, Caddy, и подводный камень с DNS
Первым делом — npm install (131 пакет встал без замечаний). Потом сгенерировал свежий ADMIN_SECRET (32 рандомных байта) и положил в .env. Дальше systemd unit с Restart=always, User=deploy и парой нюансов про ProtectHome — изначально я выкрутил его в read-only, но быстро понял, что openai-oauth и Anthropic умеют переписывать refresh-токены в ~/.codex/auth.json и ~/.claude/.credentials.json, так что директорию надо оставить writable. Иначе через сутки сервис начнёт получать 401.
Дальше — Caddy. Блок выглядит примерно так:
llm-router.pashavin.ru {
@auth path /dashboard* /admin/dashboard*
basic_auth @auth {
admin <bcrypt-хэш>
}
reverse_proxy 127.0.0.1:3200
log {
output file /var/log/caddy/llm-router.log
}
}
Ключ здесь — Basic Auth только на /dashboard*, а не на весь домен. Потому что сам прокси-эндпоинт /v1/messages авторизуется проектным токеном (Bearer), и заворачивать его ещё в Basic Auth — это сломать клиентов, которые мирно слали запросы с одним заголовком.
И тут начался цирк с DNS. Пользователь написал «днс я прописал», я обновил Caddyfile, перезагрузил конфиг — и получил страницу Vercel «DEPLOYMENT_NOT_FOUND». dig llm-router.pashavin.ru показал какой-то Vercel anycast вместо нашего Hetzner.
Оказалось, домен pashavin.ru сидит на Vercel-неймсерверах, и для него настроен wildcard *.pashavin.ru → Vercel anycast. Для конкретных сервисов (deploy, supabase) есть явные A-записи на Hetzner, а для нового llm-router такой записи не было — он автоматически попал в wildcard. Решилось добавлением явной A-записи в Vercel → Domains → DNS:
Type: A
Name: llm-router
Value: <Hetzner IP>
TTL: 60
Через минуту публичные резолверы подхватили правильный адрес, через пять — дотёр локальный кеш на самом VPS (это тоже забавная ловушка — снаружи сервис уже работает, а ты сидишь на нём и ругаешься, что не работает).
Content negotiation: браузер против ИИ-агента
Первый запуск выявил неудобство. Я повесил на корень / JSON-discovery — небольшой документ с описанием эндпоинтов, схемой авторизации, ссылкой на /docs. Это нужно для ИИ-клиентов, которые могут «прочитать» сервис и понять, как с ним работать. Но человек, открывая https://llm-router.pashavin.ru/ в браузере, видит вот это:
{"name":"llm-router","version":"0.1.0","description":"Manual-only LLM control plane..."}Полезно? Полезно. Удобно? Не очень — UI-то живёт на /dashboard. Решение лежало на поверхности — content negotiation через Accept header:
app.get('/', async (req, reply) => {
const accept = req.headers.accept || '';
if (accept.includes('text/html')) {
return reply.redirect(302, '/dashboard');
}
return reply.send(discovery);
});Браузер отправляет Accept: text/html, ... — летим на /dashboard. ИИ-клиент дёргает fetch('/') без явного Accept (или с application/json) — получает discovery как раньше. Один маленький коммит, два совершенно разных UX без ветвления роутов.
Admin API для ИИ-агентов
Второй запрос пользователя был элегантнее: «надо чтобы ИИ могли сами в моих проектах менять провайдеров и модели с помощью API». То есть у меня в дашборде уже была форма для смены defaultRoute, но ходить туда руками каждый раз, когда я хочу переключить какого-нибудь бота с Sonnet на Opus или с Claude на ChatGPT — лень.
Добавил три эндпоинта поверх существующей x-admin-token-авторизации (через ADMIN_SECRET):
| Метод | Путь | Что делает |
|---|---|---|
GET | /admin/projects/:id | Текущий config + метаданные |
PUT | /admin/projects/:id | Заменить config целиком, атомарно |
PATCH | /admin/projects/:id | Точечно: defaultRoute, enabled, отдельные routes |
Защищены x-admin-token. И тут вылез ещё один косяк — Caddy Basic Auth на /admin/* означал, что ИИ-агент должен ещё и Basic знать. Это два слоя авторизации, и второй здесь лишний (агент уже доказывает себя через токен, добавлять Basic поверх — это просто двойной геморрой). Поэтому Basic я снял и оставил его только на UI-страницах /admin/dashboard, а API-эндпоинты /admin/projects/* отдал чистые.
Документация про это дописана в /docs — так чтобы любой ИИ-клиент, прочитав discovery, знал, как переключить себе модель или провайдера через PATCH. Это маленький, но важный шаг к самообслуживающимся агентам.
Gemini как подписочный транспорт
Когда первый огонь потушили, всплыла третья задача — добавить Google Gemini как ещё один транспорт. Изначальное ТЗ предлагало MVP через api_key из AI Studio. И тут пользователь напомнил мне про инвариант: «у меня есть оплаченная подписка Google и я работаю через Gemini CLI — зачем мне api_key?».
И это полностью разворачивает архитектуру. У Gemini CLI подписочный OAuth-флоу — точно такой же, как у Claude Max и ChatGPT Plus. То есть Gemini становится ещё одним полноправным участником subscription-only кластера, а не «pay-per-token откатом». Я переписал ТЗ под OAuth-флоу, выкинул упоминания api_key, а заодно явно зафиксировал в KNOWLEDGE.md:
Subscription-only invariant. llm-router proxies only OAuth-backed subscriptions (Claude Max, ChatGPT Plus, Gemini CLI). API keys are explicitly out of scope.
Это документация для будущих ИИ-агентов, которые будут редактировать проект — чтобы они не возвращались к «а давайте добавим api_key для совместимости». Не давайте.
Метрики и итог
По результату:
- Время восстановления сервиса с момента «не работает» до зелёной галочки в браузере: ~40 минут (большая часть ушла на DNS-кеш)
- 5 коммитов в
main, все запушены - Сервис под systemd с автостартом, средний uptime теперь упирается только в
apt upgrade-перезагрузки - Caddy с автоматическим Let's Encrypt — про сертификаты можно забыть
- Admin API из 3 эндпоинтов — ИИ-агенты могут сами переключать модели
- Subscription-only инвариант зафиксирован в документации в трёх разных местах
- Gemini транспорт уехал в OAuth-флоу, не свернул в api_key
Выводы
Миграция — это не cp -r. Если бы я после переезда KZ → Hetzner прошёлся по чек-листу «какие сервисы должны быть запущены», проблему словил бы за пять минут, а не через две недели от пользователя. Любая миграция должна заканчиваться не «файлы скопировались», а «все systemd units green, все эндпоинты возвращают 200». На будущее — добавить в дисциплину деплоя обязательный smoke-test после переезда, желательно через Gatus или ему подобный health-checker, который явно показывает зелёный/красный.
DNS — самый незаметный source of truth. Wildcard на чужих неймсерверах — это бомба замедленного действия. Если у тебя домен делегирован Vercel/Cloudflare, и ты добавляешь поддомен на свой сервер, всегда проверяй, что явная A-запись побеждает wildcard. Не верь предположению «ну он же так должен резолвиться». Делай dig +short сразу после изменения, потом ещё раз через минуту, потом ещё раз с другого резолвера. Особенно опасен локальный кеш на самом VPS — снаружи сервис может работать, а ты у себя сидишь и не понимаешь, почему curl лезет на старый IP.
Content negotiation бесплатна, развилка endpoints — нет. Один и тот же URL может обслуживать и человека, и машину, если правильно посмотреть на Accept header. Развилка /api/discover vs /dashboard решает ту же задачу, но требует от клиента знания, куда стучаться, и плодит лишние роуты. Браузер всегда говорит, что хочет HTML — пользуйся этим.
Архитектурные инварианты — фиксируй явно. «У нас subscription-only, никаких api_keys» — это очевидное правило, которое я, тем не менее, забывал по дороге трижды за неделю. ИИ-агенты, которые помогают мне писать код, забывают его так же легко. Решение — записать инвариант в KNOWLEDGE.md крупно и явно, и в любой ТЗ-документ ссылаться на него явной строкой. Это та документация, которую читают не люди, а агенты, и она обязана быть формальной, как контракт.
Hardcoded URL — иногда фича. В соседнем проекте PokerBrain я получил гипотезу «при миграции Supabase URL поменялся, надо везде обновить». Реальность — URL supabase.pashavin.ru остался тем же, поменялся только service-role JWT, потому что self-hosted Supabase на новом сервере перегенерировал ключи. Меньше движений на стороне клиентов — больше стабильности. Хороший reverse-DNS-домен переживает миграцию инфраструктуры под собой, и это правильное разделение ответственности.
llm-router сейчас живёт под автоматическим рестартом, отдаёт JSON-discovery ИИ-клиентам, перенаправляет браузеры на UI, имеет admin API для самообслуживания агентов и готовится к стейджу 5 — Gemini OAuth транспорт. Всё это родилось из одной короткой жалобы «у меня не работает», и в этом, наверное, главный кайф dev-работы — каждая поломка превращается в маленький продуктовый шаг.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.