Quota Sharing Engine (Русский)
Механизм совместного использования квот справедливо распределяет ограниченную по времени квоту провайдера (например, 5-часовое окно Codex или 1500 запросов/ч для Kimi) между несколькими API-ключами, использующими одно и то же подключение.
Решаемая проблема: OmniRoute проксирует множество API-ключей через одну и ту же учётную запись вышестоящего провайдера. Без логики совместного использования всплеск запросов от ключа A может исчерпать квоту провайдера на текущий час, из-за чего ключи B и C будут заблокированы до сброса окна. Механизм предотвращает это следующим образом:
- Отслеживает потребление каждого ключа в скользящем окне по каждому измерению (%, запросы, токены, $).
- Применяет алгоритм справедливого распределения с полным использованием ресурсов: ключ может заимствовать неиспользуемые доли, пока общий пул не насыщен.
- Применяет результат на критическом пути (
chatCore.ts) до того, как запрос поступит вышестоящему исполнителю.
Алгоритм: справедливое распределение с полным использованием ресурсов
Заголовок раздела «Алгоритм: справедливое распределение с полным использованием ресурсов»Реализован в src/lib/quota/fairShare.ts.
| Условие | Режим | Поведение |
|---|---|---|
globalUsedPercent < saturationThreshold |
Щедрый | Ключ может заимствовать вплоть до общего лимита за вычетом суммарного потребления |
globalUsedPercent >= saturationThreshold |
Строгий | Строго применяется индивидуальная справедливая доля |
Значение saturationThreshold по умолчанию — 0.5 (переменная окружения QUOTA_SATURATION_THRESHOLD).
Решение по каждому измерению
Заголовок раздела «Решение по каждому измерению»Для каждого активного измерения в пуле механизм вычисляет:
fairShareAllowed = poolLimit × (allocationWeight / 100)consumed = текущее скользящее значение для этого ключа (из QuotaStore.peek)remaining = fairShareAllowed - consumedЗатем:
policy = hard: еслиconsumed > fairShareAllowedи режим строгий → заблокировать.policy = soft: еслиconsumed > fairShareAllowedи режим строгий → оштрафовать (понизить приоритет в комбинации; никогда не блокировать жёстко).policy = burst: разрешать, пока имеется запас общего пула, независимо от справедливой доли.
Абсолютный предел
Заголовок раздела «Абсолютный предел»capValue + capUnit в настройках распределения задают жёсткий потолок, не зависящий от режима или политики. Запрос всегда блокируется по любому измерению, для которого consumed >= capValue.
Проверка нескольких измерений
Заголовок раздела «Проверка нескольких измерений»Запрос блокируется, если хотя бы одно измерение в пуле требует блокировки. Измерения независимы — исчерпание 5h% не влияет на измерение weekly%.
Заимствование
Заголовок раздела «Заимствование»В щедром режиме ключ с потреблением ниже выделенной доли может использовать избыток неиспользованных долей других ключей. Формула:
maxAllowed = globalLimit - consumedByOtherKeysгде consumedByOtherKeys = consumedTotal - consumedByThisKey. Общий потолок (поле limit пула для этого измерения) всегда остаётся жёстким пределом.
Счётчик скользящего окна
Заголовок раздела «Счётчик скользящего окна»Реализован в src/lib/quota/sqliteQuotaStore.ts и redisQuotaStore.ts.
Два сегмента для каждой пары (apiKeyId, dimensionKey):
curr: текущий сегмент (floor(nowMs / windowMs))prev: предыдущий сегмент (curr - 1)
Эффективное значение скользящего окна:
effectiveBucketIndex = floor(nowMs / windowMs)bucketStartMs = effectiveBucketIndex × windowMselapsed = nowMs - bucketStartMsweight = 1 - elapsed / windowMs
effective = prev × weight + currТочность: приблизительно 99%. Погрешность составляет не более 1% размера окна на границе между сегментами (неотъемлемое свойство приближения с двумя сегментами).
Конкурентный доступ
Заголовок раздела «Конкурентный доступ»Драйвер SQLite: мьютекс в памяти для каждого ключа (apiKeyId | dimensionKey) предотвращает состояние гонки при чтении, изменении и записи. Шаблон аналогичен защите от лавинообразных запросов в src/sse/services/auth.ts.
Драйвер Redis: Lua-скрипт EVAL для атомарного увеличения значения — выполняется как одна команда Redis.
Драйверы
Заголовок раздела «Драйверы»SQLite (по умолчанию, не требует установки)
Заголовок раздела «SQLite (по умолчанию, не требует установки)»- Таблица:
quota_consumption(см. миграции073_quota_pools.sql/074_quota_consumption.sql). - Лучше всего подходит для развертываний с одним экземпляром.
- Все данные хранятся в существующей базе данных OmniRoute SQLite (
DATA_DIR/storage.sqlite).
Redis (необязательно, для нескольких экземпляров)
Заголовок раздела «Redis (необязательно, для нескольких экземпляров)»- Требуется npm-пакет
ioredis. - Счетчики хранятся в Redis; метаданные (пулы/распределения) по-прежнему хранятся в SQLite.
- Лучше всего подходит для развертываний с несколькими репликами, где счетчики должны быть общими.
Переключение драйверов
Заголовок раздела «Переключение драйверов»Через интерфейс настроек (/dashboard/settings → Quota Store) или переменные окружения:
QUOTA_STORE_DRIVER=redisQUOTA_STORE_REDIS_URL=redis://localhost:6379Настройка БД имеет приоритет над переменной окружения. Если указан driver=redis, но URL отсутствует или
ioredis не установлен, фабрика переключается на SQLite и записывает предупреждение в журнал.
Порядок выбора драйвера:
- Настройка БД
quotaStore.driver - Переменная окружения
QUOTA_STORE_DRIVER - По умолчанию:
sqlite
Многомерность
Заголовок раздела «Многомерность»Пул может иметь несколько измерений. Каждое измерение независимо:
QuotaDimension { unit: "percent" | "requests" | "tokens" | "usd", window: "5h" | "hourly" | "daily" | "weekly" | "monthly", limit: number, // глобальный предел пула для этого измерения}Пример: план Codex (5h% + weekly%):
[ { "unit": "percent", "window": "5h", "limit": 100 }, { "unit": "percent", "window": "weekly", "limit": 100 }]Чтобы запрос был разрешен, он должен удовлетворять всем измерениям.
Определитель плана
Заголовок раздела «Определитель плана»Реализован в src/lib/quota/planResolver.ts.
Приоритет (от высшего к низшему):
- Ручное переопределение в БД — таблица
provider_plans, отдельно для каждогоconnectionId. - Известный каталог —
src/lib/quota/planRegistry.ts(только данные). - Пустой план — измерения отсутствуют, требуется ручная настройка.
Известный каталог
Заголовок раздела «Известный каталог»| Провайдер | Измерения |
|---|---|
codex |
percent/5h/100, percent/weekly/100 |
glm |
tokens/5h (limit=0, неизвестен), tokens/weekly |
minimax |
tokens/5h, tokens/weekly |
bailian |
percent/5h/100, percent/weekly/100, percent/monthly/100 |
kimi |
requests/hourly/1500 |
alibaba |
requests/monthly/90000 |
openai, anthropic |
По умолчанию отсутствует — требуется ручная настройка |
Интеграция с конвейером
Заголовок раздела «Интеграция с конвейером»PRE-хук (open-sse/handlers/chatCore.ts)
Заголовок раздела «PRE-хук (open-sse/handlers/chatCore.ts)»Выполняется перед вышестоящим исполнителем, после проверок аутентификации и политик:
resolveComboTargets / handleSingleModel → enforceQuotaShare(apiKeyId, connectionId, provider, estimatedCost) → getQuotaStore().peek() для каждого измерения → fairShare.decideFairShare() → если заблокировано → вернуть 429 (buildErrorBody, жесткое правило №12) → если разрешено + снизить приоритет → установить quotaSoftPenalty=true для кандидата → executor.execute()Разрешение при сбое: если enforceQuotaShare выбрасывает исключение, запрос пропускается,
а в журнал записывается pino.warn. Это предотвращает блокировку всего трафика
из-за ошибки механизма квот.
POST-хук (учет потребления)
Заголовок раздела «POST-хук (учет потребления)»После успешного ответа:
исполнитель возвращает успешный результат → spendRecorder.recordConsumption(apiKeyId, connectionId, provider, actualCost) → getQuotaStore().consume() для каждого измерения → разрешение при сбое: ошибки записываются как pino.warn и никогда не передаются клиентуПримечание о расхождении: если consume завершается с ошибкой после отправки ответа, скользящий счетчик занижает потребление.
Сигнал насыщения от провайдера (например, anthropic-ratelimit-unified-5h-utilization)
корректирует глобальную оценку при следующем запросе.
Мягкий штраф для комбинации (open-sse/services/combo.ts)
Заголовок раздела «Мягкий штраф для комбинации (open-sse/services/combo.ts)»Когда decision.deprioritize === true:
if (candidate.quotaSoftPenalty) { score *= QUOTA_SOFT_DEPRIORITIZE_FACTOR; // по умолчанию 0.7}Штраф применяется после всех остальных коэффициентов оценки. Он снижает вероятность выбора насыщенного ключа автоматической комбинацией, не блокируя его полностью.
Обзор интерфейса
Заголовок раздела «Обзор интерфейса»/dashboard/costs/quota-share — Главная страница пулов
Заголовок раздела «/dashboard/costs/quota-share — Главная страница пулов»Компоненты (все находятся в src/app/(dashboard)/dashboard/costs/quota-share/):
| Компонент | Назначение |
|---|---|
QuotaConceptCard |
Вводная карточка, объясняющая новым пользователям совместное использование квот |
CreatePoolModal |
Создание нового пула квот (подключение + имя + начальные распределения) |
PoolCard |
Сводка по пулу: имя, подключение, количество распределений |
DimensionBar |
Составная полоса по каждому измерению: доля каждого ключа + общее использование |
AllocationTable |
Таблица с потреблением, справедливой долей, дефицитом/избытком и флагом заимствования |
BurnRateChart |
Линейный график скорости расходования EMA (ленивая загрузка Recharts через dynamic()) |
EditAllocationsModal |
Редактирование весов распределения, лимитов и политик пула |
Хуки страницы:
usePools— выполняетGET /api/quota/poolsкаждые 30 с.usePoolUsage— выполняетGET /api/quota/pools/[id]/usageпо запросу.useLocalStoragePoolMigration— запускается один раз при монтировании для переноса устаревших данных из LS.
/dashboard/costs/quota-share/plans — Настройка тарифного плана провайдера
Заголовок раздела «/dashboard/costs/quota-share/plans — Настройка тарифного плана провайдера»ProviderPlanConfigClient.tsx: раскрывающийся список для выбора провайдера, просмотра итогового плана (автоматически из каталога или с ручным переопределением) и редактирования измерений.- Изменения записываются через
PUT /api/quota/plans/[connectionId]. - При удалении используется план из каталога или пустой план.
Переменные окружения
Заголовок раздела «Переменные окружения»| Переменная | По умолчанию | Описание |
|---|---|---|
QUOTA_STORE_DRIVER |
sqlite |
Используемый драйвер: sqlite или redis |
QUOTA_STORE_REDIS_URL |
(пусто) | URL Redis, например redis://localhost:6379 |
QUOTA_SATURATION_THRESHOLD |
0.5 |
0..1; >= threshold активирует строгий режим |
QUOTA_SOFT_DEPRIORITIZE_FACTOR |
0.7 |
0..1; множитель совокупной оценки для мягкой политики |
QUOTA_CONSUMPTION_RETENTION_DAYS |
14 |
Количество дней до удаления старых сегментов quota_consumption сборщиком мусора |
Настройки БД (quotaStore.*) переопределяют переменные окружения.
Устранение неполадок
Заголовок раздела «Устранение неполадок»Redis настроен, но подключение не устанавливается
Заголовок раздела «Redis настроен, но подключение не устанавливается»Убедитесь, что ioredis установлен (npm ls ioredis) и QUOTA_STORE_REDIS_URL
доступен. При ошибке подключения фабрика переключается на SQLite (событие записывается
в журнал с уровнем warn).
peek возвращает устаревшие данные / работает в режиме fail-open
Заголовок раздела «peek возвращает устаревшие данные / работает в режиме fail-open»Если peek выбрасывает исключение, enforceQuotaShare интерпретирует результат как «разрешить» (fail-open).
Проверьте журналы pino на наличие записей quota:enforce и quota:factory, чтобы определить
первопричину.
Расхождение счётчика потребления
Заголовок раздела «Расхождение счётчика потребления»Если фактическое потребление у провайдера отличается от показаний счётчиков, это ожидаемо:
скользящее окно из 2 сегментов имеет погрешность около 1% на границах окна, а consume
выполняется после отправки ответа без ожидания результата. Сигнал насыщения (saturationSignals.ts)
считывает фактическую утилизацию провайдера с TTL 30 с и соответствующим образом корректирует globalUsedPercent.
Для скорости расходования пула отображается «нет данных»
Заголовок раздела «Для скорости расходования пула отображается «нет данных»»Для computeBurnRate требуется не менее 2 исторических образцов. Для новых пулов без предыдущих
вызовов consume будут отображаться tokensPerSecond: 0 и timeToExhaustionMs: null.
Миграция из localStorage
Заголовок раздела «Миграция из localStorage»При первой загрузке /dashboard/costs/quota-share хук useLocalStoragePoolMigration
проверяет:
localStorage.getItem("omniroute:quota-share:pools")не является пустым.GET /api/quota/poolsвозвращает[](БД пуста).
Если оба условия выполняются, хук пакетно отправляет каждый устаревший пул в POST /api/quota/pools,
а затем удаляет ключ localStorage. Миграция идемпотентна: условие 2 предотвращает
повторную миграцию.
Классификация внутренней стратегии
Заголовок раздела «Классификация внутренней стратегии»quota-share — это стратегия маршрутизации только для внутреннего использования (INTERNAL_ROUTING_STRATEGY_VALUES в
src/shared/constants/routingStrategies.ts). Она используется исключительно системными
комбинациями пулов qtSd/ и намеренно исключена из ROUTING_STRATEGY_VALUES, поэтому никогда
не отображается в UI или API как доступный пользователю вариант.
Покрытие тестами
Заголовок раздела «Покрытие тестами»Движок quota-share поставляется с двумя уровнями автоматизированного тестирования:
| Набор тестов | Команда | Что проверяется |
|---|---|---|
| Модульные (29 тестов) | node --import tsx/esm --test tests/unit/quota-share-strategy.test.ts |
Планировщик DRR, блокировка при насыщении, ограничения параллелизма, вычисление fairShare, постановка невыполненных запросов в очередь |
| Интеграционная матрица | npm run test:combo:matrix |
Сквозное решение о маршрутизации через реальный конвейер комбинаций; справедливость DRR и снижение приоритета при насыщении через рабочие точки интеграции (registerQuotaFetcher, setLKGP, __setHeadroomSaturationFetcherForTests) |
Интеграционная матрица запускается в CI вместе со всеми 19 общедоступными стратегиями. Модульный набор можно запускать отдельно.
Краткое описание схемы БД
Заголовок раздела «Краткое описание схемы БД»Три таблицы, добавленные миграциями 078, 079 и 085:
quota_pools+quota_allocations— определения пулов и распределения по ключам.quota_consumption— скользящие счётчики с 2 сегментами для каждой пары(apiKeyId, dimensionKey).provider_plans— задаваемые вручную переопределения тарифных планов провайдеров (измерения в формате JSON для каждого connectionId).
Все таблицы добавляются с помощью идемпотентных миграций CREATE TABLE IF NOT EXISTS.
HagiCode
HagiCode — агентная среда разработки со структурированными процессами, параллельным выполнением несколькими агентами и интерфейсами Hero Dungeon.
Превращайте идеи в полезное ПО с более умным, быстрым и увлекательным агентным рабочим процессом.

- SmartСтруктурированные процессы превращают намерение в исполнимый путь от идеи до готового изменения.
- EfficientМультиагентные процессы параллельно продвигают исследование, реализацию и проверку.
- FunHero Dungeon делает длительную совместную разработку наглядной и увлекательной.