Multiproxy — лёгкий, самодостаточный OpenAI-совместимый прокси к любому числу AI-провайдеров через один endpoint. Ротация API-ключей, авто-бан «мёртвых» ключей, fallback между провайдерами, стриминг, кэш ответов, лимиты по токенам и двусторонний мост Anthropic ↔ OpenAI (Claude Code и Anthropic SDK).
Multiproxy провайдер-агностичен: он не «знает» ни одного провайдера в коде — ты
описываешь их в config.json. Работает с любым апстримом, который говорит на
OpenAI Chat Completions (OpenAI, OpenRouter, Groq, Together, локальный Ollama/LM Studio,
любые reseller/aggregator-эндпоинты и т.д.).
Версия 1.7.4. Стек: FastAPI + httpx + uvicorn. Zero-infra: ни БД, ни Redis. Аудит кода —
docs/REVIEW-1.5.md(полный аудит перед 1.6.0, security-ревью админки — там же, #26–#40),docs/REVIEW.md. План развития —docs/ROADMAP.md. Позиционирование относительно LiteLLM — раздел ниже.Обновляетесь с 1.5.0? В 1.6.0 три несовместимых изменения:
rpm_limit: 0теперь действительно выключает лимитер (а не даёт 100),by_dayсчитается по UTC (вернуть прежнее —usage_day_tz: "local"), терминальное исчерпание rate-limit доходит до клиента как429вместо502(вернуть —retry.propagate_rate_limit: false). Подробнее —CHANGELOG.md.
(English: see README.en.md.)
- Единый OpenAI endpoint к произвольному набору провайдеров.
- Ротация ключей round-robin с временным баном ключей на
key_dead_for_sпри 401/402/403 и признаках исчерпания баланса/квоты; общий дедлайн 30 с. - Cross-provider fallback для «голых» моделей при падении провайдера (502/503).
- Ретраи и устойчивость (с 1.5.0): один «горячий» ключ ретраится с
экспоненциальным backoff (
retry.max_attempts/backoff_base_s/backoff_max_s, уважаяRetry-Afterи общий дедлайн); терминальное 429-исчерпание нейтрально для circuit breaker; оборванный апстрим-стрим завершается явным error-событием (chat) /event: error+message_stop(Anthropic), а не тихим обрывом. - Маршрутизация из конфига:
provider/model-префикс, алиасы, детерминированный выбор при коллизии имён (default_provider). - Мост
/v1/messages(Anthropic Messages API): Claude Code / Anthropic SDK работают с любой моделью каталога; tools, tool_use/tool_result, system, reasoning/thinking, SSE-стриминг. /v1/embeddings— OpenAI-совместимый passthrough эмбеддингов через тот же механизм ротации ключей, fallback и кэша, что и чат.- Кэш ответов (LRU + TTL) для детерминированных не-streaming запросов без tools
(включая эмбеддинги), с ограничением по байтам (
max_bytes/max_entry_bytes), инвалидацией при смене маршрутизации иPOST /admin/cache/clear. - Лимиты и защита: per-token request-count, token-budget, model-ACL,
скользящее окно RPM (глобальное, пер-токенное
proxy_token_rpmи пер-IPrpm_limit_per_ip), in-process fail2ban по IP, опциональная авторизация/health. - Транзакционный hot-reload (с 1.6.0): конфиг применяется целиком или не
применяется вообще — провалившаяся перезагрузка не оставляет систему в
полу-обновлённом состоянии.
--print-configпоказывает эффективные значения всех дефолтов с замаскированными секретами. - Точный учёт токенов в стриминге:
force_stream_usageдомешиваетstream_options.include_usageв OpenAI-стриминг, если клиент сам его не запросил. - Наблюдаемость:
GET /metricsв формате Prometheus (опц.metrics_token), структурные JSON-логи (log_format: json) и оценка стоимости в $ (model_pricing). - Скорость генерации (TPS): честная скорость стрима (
ewma_tps) и более грубая не-стримовая оценка (ewma_tps_effective) по провайдеру, плюс системная пропускная способностьtps_1m— вGET /health,GET /metrics(multiproxy_provider_ewma_tps[_effective],multiproxy_completion_tokens_total,multiproxy_tps_1m) и на дашборде. - Дашборд (
dashboard.py) с графиками, метриками, панелью TPS и живой проверкой ключей — список провайдеров строится динамически из конфига. - Готовая упаковка:
Dockerfile+docker-compose.yml, GitHub Actions CI (ruff + pytest +--check-configна 3.10/3.11/3.12),Makefile,pyproject.toml.
- Python 3.10+ (рекомендуется). Импортируется и на 3.9 (
from __future__ import annotations). - Зависимости —
requirements.txt:fastapi,uvicorn,httpx,socksio(для SOCKS-провайдеров),pytest(тесты).
python3 -m venv venv && . venv/bin/activate
pip install -r requirements.txtДля воспроизводимых сборок (прод, CI) добавьте пины из
constraints.txt — там зафиксированы точные версии, на
которых проверен набор тестов 1.6.0, включая транзитивные starlette/httpx
(от их поведения зависят обработка отменённого StreamingResponse и завершение
оборванного апстрим-стрима):
pip install -r requirements.txt -c constraints.txtcp config.example.json config.json # опиши своих провайдеров и токены
mkdir -p keys
cp keys.example.txt keys/openai.txt # положи реальные ключи (по одному в строке)
# ...по файлу ключей на каждого провайдера (см. keys_file в конфиге)
python3 multiproxy.py # слушает 127.0.0.1:11440
# либо: uvicorn multiproxy:app --host 127.0.0.1 --port 11440Переменные окружения:
| Переменная | По умолчанию | Назначение |
|---|---|---|
PORT |
11440 |
Порт прослушивания |
HOST |
127.0.0.1 |
Хост. 0.0.0.0 — только за файрволом/reverse-proxy |
CONFIG_FILE |
./config.json |
Путь к конфигу (hot-reload раз в 2 с) |
- Голое имя модели (
glm-5.2) → провайдер, у которого она есть; при коллизии имён побеждаетdefault_provider, остальные становятся fallback-кандидатами. Работает и когда само имя модели содержит/(конвенция OpenRouter/HF, напримерmeta-llama/llama-3.3-70b-instruct) — начиная с 1.3.1 такая модель, если задублирована на нескольких провайдерах, тоже получает честный cross-provider fallback, а не только явные provider/model-роуты. - Явный маршрут
провайдер/модель(openrouter/openai/gpt-4o) → жёстко этот провайдер, без fallback. - Алиас из
model_aliases(fast→groq/llama-3.3-70b-versatile) → удобное имя. - Авто-алиасы (с 1.4.0): для каждого включённого провайдера и каждой его
модели автоматически создаётся алиас
provider-model → provider/model(напримерopenai-gpt-4o → openai/gpt-4o) — весь каталог доступен по явным именам без ручного заполненияmodel_aliases; ручные записи побеждают при совпадении имени. Видны вGET /v1/models.
Клиент всегда шлёт обычный OpenAI-запрос; провайдера выбирает multiproxy.
Ниже — пример из config.example.json (замени своими). Провайдеры в коде не
зашиты: любой OpenAI-совместимый эндпоинт добавляется одной записью.
| Провайдер | base_url (пример) | Особенность в примере |
|---|---|---|
openai |
api.openai.com | базовый провайдер, default_provider |
openrouter |
openrouter.ai | модели со слэшем (openai/gpt-4o) |
groq |
api.groq.com | ключи gsk_… → raw_keys: true |
local |
127.0.0.1:11434 (Ollama) | локальный, без реального ключа |
via_socks |
(любой) | доступ через proxy: socks5://… |
Каждый провайдер:
"openai": {
"base_url": "https://api.openai.com/v1/chat/completions",
"keys_file": "keys/openai.txt", // опционально — дефолт keys/openai.txt
"models": ["gpt-4o-mini", "gpt-4o"],
"connect_timeout": 5.0,
"read_timeout": 300.0,
"key_dead_for_s": 600,
"proxy": "socks5://127.0.0.1:1080", // опционально
"raw_keys": false, // true = слать ключи без нормализации
"embeddings_url": "https://api.openai.com/v1/embeddings", // опционально, см. ниже
"enabled": true // опционально; false — провайдер пропускается целиком
}embeddings_url — опциональный явный URL эмбеддингов для провайдера. Если не
задан, эффективный URL выводится из base_url заменой
/chat/completions → /embeddings (для нестандартных апстримов задай явно).
keys_file необязателен (дефолт — keys/{провайдер}.txt рядом с конфигом).
Альтернатива — keys_dir: директория, где все *.txt сливаются в один
список ключей (по имени файла), а hot-reload реагирует и на правку, и на
добавление/удаление файла — удобно, когда ключи приходят пачками и не хочется
вручную сводить их в один файл. Если заданы оба — побеждает keys_dir.
Интерполяция переменных окружения — ${env:VAR_NAME} работает в
base_url/embeddings_url/anthropic_base_url и прямо в строках файла
ключей (keys_file/keys_dir), например ключ ${env:OPENAI_KEY_1} в файле
резолвится из переменной окружения при загрузке. Узкий, сознательно
нешаблонизированный механизм — только эти поля. Незаданная переменная — это
жёсткий отказ (--check-config вернёт FAIL, прямой запуск завершится с
ошибкой), а не тихая подстановка пустой строки.
| Параметр | Значение |
|---|---|
| Base URL | http://<host>:11440/v1 |
| API Key | один из proxy_tokens (Authorization: Bearer <token> или x-api-key) |
| Формат | OpenAI-совместимый |
curl -s http://127.0.0.1:11440/v1/chat/completions \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"model":"smart","messages":[{"role":"user","content":"Привет"}]}'Эмбеддинги — тот же токен, та же ротация ключей/fallback/кэш:
curl -s http://127.0.0.1:11440/v1/embeddings \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"model":"openai/text-embedding-3-small","input":"Привет, мир"}'POST /v1/chat/completions— основной OpenAI-эндпоинт (stream и non-stream).POST /v1/messages— Anthropic Messages API (для Claude Code).POST /v1/embeddings(алиасPOST /embeddings) — OpenAI-совместимый passthrough эмбеддингов: ротация ключей, cross-provider fallback на 502/503, кэш ответов.GET /v1/models— модели, доступные токену (требует авторизации).GET /v1/usage(алиасGET /usage) — токен смотрит собственные бюджет/расход:budget,used,remaining,used_pct,exceeded,requests,allowed_models(токен маскируется). Аутентификация как везде, но бюджет не enforced — исчерпанный токен всё равно видит свой статус.GET /health— статус провайдеров (включаяewma_tps/ewma_tps_effectiveна провайдера) и fail2ban, плюс системнаяtps_1m(без авторизации по умолчанию; опционально требуетhealth_token, см. «Безопасность»).GET /metrics— метрики в формате Prometheus (text-exposition v0.0.4): по провайдерам (включая скорость генерацииmultiproxy_provider_ewma_tps[_effective]иmultiproxy_completion_tokens_total{provider,model}), прокси-токенам (значение токена маскируется), моделям, системной пропускной способности (multiproxy_tps_1m) и всему прокси, плюс опциональная оценка стоимости в $. Открыт по умолчанию; опционально защищаетсяmetrics_token.
| Ключ | Тип | Описание |
|---|---|---|
providers |
object | Провайдеры: base_url, models, таймауты, key_dead_for_s, опц. proxy, raw_keys, embeddings_url, enabled (по умолчанию true; false — провайдер пропускается целиком), keys_file (опц., дефолт keys/{provider}.txt) или keys_dir (опц., сливает все *.txt из директории; побеждает при заданных обоих) |
default_provider |
string | Победитель при коллизии имён моделей |
proxy_tokens |
object | { "<token>": <лимит запросов> }; -1 = без лимита. Пусто = auth выключен |
proxy_token_budgets |
object | { "<token>": <лимит суммарных токенов> } |
proxy_token_models |
object | { "<token>": ["provider", "provider/model", ...] } — ACL по моделям |
model_aliases |
object | { "alias": "provider/model" }; ручные записи побеждают авто-сгенерированные provider-model алиасы (см. «Как работает маршрутизация») |
response_cache |
object | { enabled, max_entries, ttl_s, max_bytes, max_entry_bytes }. ttl_s <= 0 выключает кэш. max_bytes (256 MiB) ограничивает суммарный объём, max_entry_bytes (1 MiB) — один ответ: без них max_entries считал штуки, а не байты |
rpm_limit |
int | Глобальный лимит запросов/мин на токен (по умолчанию 100). 0 выключает лимитер (в 1.5.0 0 молча превращался в 100) |
rpm_limit_per_ip |
int | Лимит запросов/мин на IP-адрес, 0 = выключен. Работает и при выключенной авторизации, где пер-токенный лимитер бесполезен |
proxy_token_rpm |
object | { "<token>": <RPM> } — пер-токенный override глобального rpm_limit |
fail2ban |
object | { enabled, max_failures, fail_window_s, ban_duration_s } |
trust_forwarded_for |
bool | Доверять X-Forwarded-For/X-Real-IP (только за reverse-proxy). По умолчанию false. Без непустого trusted_proxies заголовки игнорируются и в лог пишется warning |
trusted_proxies |
array | IP/CIDR доверенных прокси (["10.0.0.1", "172.18.0.0/16"]). Заголовки читаются только если peer-сокет входит в этот список; цепочка XFF разбирается справа налево, доверенные хопы отбрасываются, первый недоверенный адрес = клиент. Мусор вместо IP → берётся адрес соединения |
log_tokens |
array | Токены, чьи промпты/ответы писать в prompts_log.jsonl |
force_stream_usage |
bool | Домешивать stream_options.include_usage: true в OpenAI-стриминг (не Anthropic-мост), если клиент сам не запросил usage. По умолчанию true |
health_token |
string | Токен для GET /health (Authorization: Bearer или x-api-key, constant-time сравнение). По умолчанию не задан — /health открыт |
state_backend |
string | Хранилище durable-состояния: sqlite (по умолчанию, один файл) или memory (без персистентности). Счётчики usage/бюджетов переживают рестарт |
state_path |
string | Путь к SQLite-файлу состояния. Пусто при sqlite — state.db рядом с token_usage.json |
routing_strategy |
string | Порядок провайдеров-кандидатов для bare-model коллизий: default (порядок конфига), round_robin, latency_weighted, least_error. По умолчанию default |
circuit_breaker |
object | { enabled, failure_threshold, cooldown_s } — provider-level breaker: после N подряд сбоев 502/503 провайдер уводится в конец на cooldown_s; один успех закрывает. По умолчанию enabled:true, failure_threshold:5, cooldown_s:30 |
retry |
object | { max_attempts, backoff_base_s, backoff_max_s, backoff_jitter, propagate_rate_limit } — ретраи внутри одного провайдера с экспоненциальным backoff (уважает Retry-After, кэпируется общим дедлайном запроса); 429-исчерпание нейтрально для circuit breaker. backoff_jitter (доля в [0,1), по умолчанию 0) де-синхронизирует волны ретраев. propagate_rate_limit (по умолчанию true) отдаёт клиенту реальный 429 + Retry-After вместо непрозрачного 502 |
usage_day_tz |
string | Часовой пояс дневных бакетов by_day: utc (по умолчанию с 1.6.0) или local (прежнее поведение — при смене TZ/DST давало «сутки» в 23 или 25 часов) |
usage_retention_days |
int | Сколько дней хранить бакеты by_day (90; 0 = хранить всё). Файл перезаписывается целиком на каждом флаше, поэтому бесконечная история — растущая цена |
prompts_log_max_bytes |
int | Размер, после которого prompts_log.jsonl ротируется в .1 (64 MiB; 0 = без ротации). В 1.5.0 файл рос без предела |
prompts_log_max_response_chars |
int | Обрезка тела ответа в логе промптов (1 MiB; 0 = без обрезки) |
models_hide_unavailable |
bool | Скрывать в /v1/models модели и алиасы провайдера без ключей или с открытым брейкером (по умолчанию true) |
strict_config |
bool | Отвергать конфиг с неизвестными ключами вместо WARNING (по умолчанию false) |
strict_alias_collisions |
bool | Отвергать конфиг при коллизии авто-алиасов вместо WARNING (по умолчанию false) |
request_deadline_s |
float | Общий дедлайн запроса на все провайдеры-кандидаты и все ретраи (30.0) |
max_provider_candidates |
int | Сколько провайдеров пробовать при fallback (3) |
max_request_bytes |
int | Лимит тела запроса (16 MiB) |
max_response_bytes |
int | Лимит буферизуемого не-стримового ответа (32 MiB) |
config_check_interval_s |
float | Как часто проверять mtime конфига (2.0) |
inflight_ttl_s |
float | Через сколько истекает «повисшая» бюджетная резервация (300.0) |
detail_flush_interval_s |
float | Интервал коалесцирующего сброса файлов usage на диск (2.0) |
cb_half_open_probe_s |
float | Сколько ждать результата half-open-пробы, прежде чем выдать новую (35.0) |
admin_token |
string | Токен для Admin API виртуальных ключей (/admin/keys/*, constant-time сравнение). Пустой = Admin API выключен (403) |
metrics_token |
string | Токен для GET /metrics (Authorization: Bearer или x-api-key, constant-time сравнение). Пустой = /metrics открыт |
log_format |
string | Формат логов: text (по умолчанию) или json (структурные single-line JSON-записи) |
model_pricing |
object | Цены моделей для оценки стоимости в /metrics: { model: { prompt: $/1M, completion: $/1M } }. Пусто = метрики стоимости выключены |
Токены из
proxy_token_budgets/proxy_token_modelsавто-регистрируются вproxy_tokens(безлимитный счётчик) с предупреждением — лучше добавлять их явно.
Помимо статичных proxy_tokens из конфига можно создавать виртуальные ключи
(vk-...) на лету, без правки config.json. Каждый ключ несёт: owner,
budget_tokens (-1 = без лимита), request_limit (-1 = без лимита), models
(ACL; пусто = все), expires_at (Unix-время или ttl_s при создании) и
enabled. Проверяются в том же пути аутентификации как fallback после proxy_tokens.
Реестр хранится в durable state store и переживает рестарт при sqlite.
Эндпоинты (требуют Authorization: Bearer <admin_token>):
| Метод | Путь | Действие |
|---|---|---|
| GET | /admin/keys |
Список ключей (с текущим расходом) |
| POST | /admin/keys |
Создать ключ (опц. token, owner, budget_tokens, request_limit, models, ttl_s/expires_at) |
| GET | /admin/keys/{token} |
Ключ + расход |
| PATCH | /admin/keys/{token} |
Обновить поля (бюджет/ACL/enabled/...) |
| DELETE | /admin/keys/{token} |
Отозвать ключ |
| POST | /admin/keys/{token}/rotate |
Выдать новый токен, сохранив метаданные и расход (?carry_usage=false — начать с нуля) |
| POST | /admin/usage/reset |
Сбросить счётчики: {"token": "..."} или {"all": true} |
| POST | /admin/cache/clear |
Выбросить все закэшированные ответы |
С 1.6.0 payload Admin API валидируется строго: неверный тип поля даёт
400, а не молчаливое приведение. Раньше{"models": "model-b"}(строка вместо списка) превращалось в пустой список — то есть в разрешение всех моделей, — а{"enabled": "false"}оставляло ключ включённым, потому чтоbool("false") is True.
Файл ключей (keys_file, опционально — или keys_dir для нескольких файлов
сразу) — по одному ключу в строке; # и пустые строки игнорируются, строки
вида ${env:VAR} резолвятся из окружения при загрузке. «Голому» токену
дописывается sk-. Ключи со структурой отправляются как есть:
sk-…, tp-…/xai-… (дефис), gsk_…/hf_… (подчёркивание), id.secret (точка).
Полное отключение нормализации — "raw_keys": true у провайдера (см. keys.example.txt).
Multiproxy закрывает те же повседневные сценарии, что и proxy-режим LiteLLM (один endpoint, много провайдеров, ротация ключей, бюджеты, лимиты, кэш, OpenAI+Anthropic), но осознанно не гонится за паритетом.
Выбирай multiproxy, если нужно: лёгкий self-hosted шлюз без инфраструктуры (ни Postgres, ни Redis), первоклассная ротация десятков ключей на провайдера, запуск в один процесс (~50 МБ), hot-reload конфига, простой дашборд.
Выбирай LiteLLM, если нужно: нативная трансляция к не-OpenAI API (Bedrock, Vertex/Gemini, Azure, Cohere), много типов эндпоинтов (embeddings, images, audio, rerank, batch, files), спенд-трекинг и virtual keys в БД, teams/orgs, горизонтальное масштабирование, готовые интеграции логирования.
Осознанные ограничения 1.0: passthrough (работает с уже-OpenAI-совместимыми
апстреймами; из трансляций — только OpenAI↔Anthropic); эндпоинты —
/chat/completions, /v1/messages, /v1/embeddings (без images/audio/rerank/batch/files);
состояние in-process (не шарится между воркерами, кэш/лимиты не
переживают рестарт); учёт токенов best-effort. Планы по снятию части этих
ограничений — в docs/ROADMAP.md.
DASH_HOST=127.0.0.1 DASH_USER=admin DASH_PASS=<пароль> python3 dashboard.py
# http://127.0.0.1:8766Статус прокси/провайдеров, живые/мёртвые ключи, расход по токенам и моделям,
скорость генерации (TPS) по провайдерам и системная пропускная способность,
графики за час/день/неделю, кнопка живой проверки ключей. Без DASH_PASS
авторизация выключена — не поднимай дашборд на публичный интерфейс без пароля.
Дашборд рассчитан на Linux/systemd (использует /proc, journalctl).
Секреты (config.json, keys/) не запекаются в образ — монтируй их read-only.
docker build -t multiproxy:1.0.0 .
docker run -d --name multiproxy -p 11440:11440 \
-v "$(pwd)/config.json:/app/config.json:ro" \
-v "$(pwd)/keys:/app/keys:ro" \
multiproxy:1.0.0Либо через docker-compose.yml (сервис multiproxy + опциональный профиль
dashboard для dashboard.py на :8766):
docker compose up -d --build # только прокси
docker compose --profile dashboard up -d # прокси + дашбордОбраз — non-root пользователь, HEALTHCHECK на GET /health (учитывает
опциональный health_token, см. комментарий в Dockerfile).
make install # зависимости из requirements.txt
make dev # + requirements-dev.txt (ruff, pytest)
make test # pytest -q
make lint # ruff check .
make check-config # python multiproxy.py --check-config config.example.json
make run # python multiproxy.py
make docker-build # docker build -t multiproxy:1.0.0 .GitHub Actions (.github/workflows/ci.yml) на каждый push/PR гоняет ruff,
pytest и --check-config на Python 3.10, 3.11 и 3.12.
- Секреты не коммитятся.
config.json(токены) иkeys/(ключи) — в.gitignore. В репозиторий идутconfig.example.jsonиkeys.example.txt. - Токены сравниваются в constant-time (
hmac.compare_digest); используй длинные случайные. trust_forwarded_for: falseпо умолчанию — при прямом доступе клиент не подделаетX-Forwarded-Forдля обхода fail2ban. Включай только за доверенным reverse-proxy.- Bind
127.0.0.1по умолчанию; для внешнего доступа — reverse-proxy (TLS) + firewall. /healthпо умолчанию отдаётся без авторизации — не выставляй наружу без ограничения. Задайhealth_tokenв конфиге, чтобы требоватьAuthorization: Bearer <token>илиx-api-key: <token>.
python3 -m pytest -q # 491 тест
python3 -m py_compile multiproxy.py dashboard.py
python3 multiproxy.py --check-config config.example.json # валидация конфига, exit 0/1
python3 multiproxy.py --print-config config.example.json # эффективный конфиг, секреты замаскированы--check-config [путь] (по умолчанию — CONFIG_FILE/config.json) загружает
и проверяет конфиг офлайн, без старта сервера: печатает PASS или список
найденных проблем и завершается кодом 0/1. Тот же шаг гоняется в CI на
каждый push/PR (см. «Разработка / CI» ниже).
--print-config [путь] печатает конфиг после применения всех значений по
умолчанию, с замаскированными секретами и числом реально загруженных ключей по
каждому провайдеру: около двух десятков ключей дефолтятся молча, и до 1.6.0
узнать их фактические значения можно было только чтением исходника.
Дополнительно: check_all_keys.py (живая проверка ключей),
audit_multiproxy_runtime.py (нагрузка/стриминг/фаззер/hot-reload),
backup_to_drive.sh (бэкап через rclone).
MIT © 2026 Oleg Alioshin (oleg494).