@ykurilov/agent-chat-limits
v0.75.0
Published
Лимиты расходов для чата с агентом: бюджет в долларах на человека, на чат и на весь сервис — в любых окнах (сутки, неделя, месяц, N часов от первого запроса). Отдельное расширение: приложению без лимитов пакет не нужен. Ядро записывает в журнал расхода то
Readme
@ykurilov/agent-chat-limits
Лимиты расходов для чата с агентом: бюджет в долларах на человека, на чат и на весь сервис — в любых окнах (сутки, неделя, месяц, N часов от первого запроса). Отдельное расширение: приложению без лимитов пакет не нужен. Ядро записывает в журнал расхода токены каждого ответа и его стоимость в долларах (токены × цены из профиля модели у того провайдера, который ответил, на момент хода). Этот пакет только складывает готовые суммы.
Подключение
import { createSpendingLimits } from "@ykurilov/agent-chat-limits";
const limits = createSpendingLimits({
storage, // то же хранилище, что у чата: журнал расхода лежит в нём
catalog, // тот же каталог моделей, что у чата: из него берутся цены
budget: async ({ userId, chatId }) => ({ user: planOf(userId).monthlyUsd, chat: 2 }), // хоть одно из двух
// period: "month" (по умолчанию) | "week" | "day" | "none" — или { user: "month", chat: "day" }, если у областей разный срок;
// неделя здесь всегда с понедельника, другой первый день — окно с unit: "week" и weekStartsOn (ниже)
// timeZone: "Europe/Moscow" — сутки, недели и месяцы по этому поясу; без него — по UTC
// warnAt: 0.8 — с какой доли показывать предупреждение
// unpricedModels: "reject" (по умолчанию) | "ignore"
});
const handler = createChatHandler({ /* … */, limits });Окна лимитов
Вместо budget и period можно задать любой набор окон. Каждое окно — своя область, свой срок и свой лимит.
Ход начинается, только если не исчерпано ни одно окно.
const limits = createSpendingLimits({
storage, catalog,
timeZone: "Europe/Moscow",
windows: [
{ id: "5h", scope: "user", kind: "rolling", hours: 5, limitUsd: 0.3, carryOverrun: true },
{ id: "week", scope: "user", kind: "calendar", unit: "week", weekStartsOn: 1, limitUsd: 1 },
{ id: "service-day", scope: "service", kind: "calendar", unit: "day", limitUsd: 20 },
],
turn: { minRemainingUsd: 0.03, maxCostUsd: 0.1, maxToolCalls: 40 },
onThreshold: (event) => notifyAdmin(event), // 80% и 100% по умолчанию, см. thresholds
});- Область (
scope):user— все чаты человека,chat— один чат,service— весь сервис, все люди вместе. - Срок (
kind):calendar— сутки, неделя или месяц (unit) по поясу окна (timeZone, иначе пояс лимитов, иначе UTC). Неделя начинается сweekStartsOn: 1 — понедельник (по умолчанию), 7 — воскресенье.rolling—hoursчасов от первого запроса. Окно кончилось — следующее начнётся со следующего запроса, а не сразу.total— на всё время, без обнуления.
- Лимит (
limitUsd): число или функция({ userId, chatId }) => число | null. Функция зовётся на каждую проверку, так что смена тарифа действует сразу;null— у этого человека окна нет. - Перерасход. Начатый ход доделывается, даже если вышел за лимит. С
carryOverrun: trueто, что вышло сверх лимита, переносится в начало следующего окна: в окне 5 часов с лимитом $0.30 ход на $0.45 оставит $0.15 долга на следующее окно. Долг больше лимита гасится по окнам: первый запрос (даже отклонённый) открывает окно с долгом, в следующее идёт остаток. - Хранилище. Скользящим окнам, ручному сбросу, личным настройкам и порогам нужны методы состояния хранилища
(
getSpendingLimitState,saveSpendingLimitState). В хранилищах SDK (память, SQLite, PostgreSQL) они есть. Нет методов — ошибка при создании. - Окна и
budgetвместе задать нельзя: ошибка при создании.
Лимит сообщений
Окно считает доллары (limitUsd) или сообщения человека (limitMessages) — ровно одно из двух; окна денег и сообщений
можно смешивать.
windows: [{ id: "day-messages", scope: "user", kind: "calendar", unit: "day", limitMessages: 20 }],
exemptUsers: ({ userId }) => staff.has(userId), // без лимитов: все окна, и денежные
onMessageThreshold: (event) => notify(event.userId, `Осталось ${event.remaining}`),- Считаются отправка и правка. Переделка, повтор, служебные сообщения, ответы на подтверждения и виджеты, импорт и песочница — нет. Место берётся до записи сообщения; не записалось — место возвращается.
- Счёт лежит в состоянии лимитов: удаление чата его не сбрасывает. Два сообщения на последнее место — проходит одно.
- Отказ — 429
spending_limit_reachedсunit: "messages"; экран: «Осталось 1 сообщение», «Лимит сообщений исчерпан». exemptUsersснимает окна, но не потолки хода (maxCostUsd,maxToolCalls). Его сбой —logError, ход не начинается.- Пороги —
onMessageThreshold({ windowId, threshold, share, used, limit, remaining, windowStart, resetsAt, userId, chatId? }), по разу на порог в окне;onThresholdсообщений не видит. - Личный лимит, блокировка и сброс — как у денег (
setUserSettings,resetWindow).
Правила хода (turn)
minRemainingUsd— запас на старт: новый ход начинается, только если в каждом окне осталось не меньше. Отказ — 429 сreason: "reserve", аGET …/spendingотдаётexhaustedс той же причиной.maxCostUsdиmaxToolCalls— потолки одного хода. Дошёл ход до потолка — следующий шаг идёт без инструментов и с просьбой подвести итог. Ответ кончается итогом, а не обрывом.overrun:"finish"(по умолчанию) — ход доделывается, перерасход остаётся в окне или переносится;"stop"— потолок денег хода не больше остатка окна, ход на остатке заканчивается итогом.
Человек: свой лимит, блокировка, сброс
await limits.setUserSettings("anna", { limits: { week: 5, "5h": null } }); // свой лимит; null — без лимита
await limits.setUserSettings("anna", { limits: { week: "default" } }); // снова общий
await limits.setUserSettings("anna", { blocked: true }); // ходы не начинаются
await limits.getUserSettings("anna"); // { blocked, limits }
await limits.resetWindow({ windowId: "week", userId: "anna" }); // расход окна до этого мига не считаетсяСвои лимиты действуют на окна user и chat; у окна service своего лимита у человека нет. С budget своих лимитов нет
(ошибка: лимит человека — в самой функции budget), а блокировка работает. После ручного сброса пороги окна шлются снова. Блокировка — отказ 429 с
reason: "blocked". Ручной сброс окна чата — с chatId, окна сервиса — без userId. Скользящее окно после сброса
закрывается: новое начнётся с первого запроса.
Пороги
onThreshold(event) зовётся один раз на порог в каждом окне: { windowId, scope, period, hours?, threshold, share, spentUsd,
limitUsd, windowStart, resetsAt, userId, chatId? }. Пороги — thresholds (по умолчанию [0.8, 1]). Проверка идёт после
записи расхода (afterSpending): ответа агента и картинок. Ход обработчик не ждёт, ошибка уходит в logError. Две копии
сервера одно событие дважды не пришлют: отметка о пороге пишется по версии.
Что видит браузер
GET …/spending отдаёт самое тесное окно (windowId, period, hours, share, resetsAt, reason) и все окна
списком windows — только доли и время сброса, без долларов. Надписи: «Использовано 85% лимита на 5 ч», «Лимит сервиса
на день исчерпан», «Лимита на 5 ч не хватит на новый ответ, обновится 7 октября в 17:00», «Новые ответы для вас приостановлены».
Доллары по каждому окну — в getSpending: windows: [{ id, scope, period, hours?, limitUsd, spentUsd, remainingUsd, share,
state, start, resetsAt, carriedUsd }] и blocked.
Бюджет (прежний способ)
budget — функция приложения: какой лимит у этого человека и этого чата. Вернуть можно { user }, { chat } или оба;
undefined в поле — этого лимита нет. Тарифы, промокоды и бонусы остаются в приложении.
- Период.
month— календарный месяц,day— сутки,none— без обнуления. Границы считаются по UTC, а сtimeZone(пояс IANA, например"Europe/Moscow") — по полуночи этого пояса: без него московский суточный лимит обнулялся бы в 03:00. Переход на летнее время учтён: сутки бывают 23 и 25 часов. Тот же пояс — запасной пояс сегодняшней даты агента (agent.timeZoneзадавать не нужно; если задан, должен совпадать). Пояс не из базы IANA — ошибка при создании. Расход за прошлый период в журнале остаётся, просто в новый период не входит. Ход и сжатие относятся к тому периоду, в котором они начались: ход, начатый 31-го в 23:59, целиком идёт в старый месяц. - Что входит в расход. Ответы агента (со всеми шагами хода, включая вызовы инструментов) и фоновое сжатие истории,
в том числе сжатие, которое не удалось, но уже было оплачено.
Веб-инструменты (
web-tools) входят, если им переданspendingс ценой вызова (README пакета): расходweb_tools. - Одновременные ходы. Проверка не резервирует деньги, поэтому у человека одновременно идут не больше трёх ответов
(
maxConcurrentTurnsPerUserвcreateChatHandler): перерасход — не больше трёх ходов сверх лимита. - Цены. Лежат в профиле модели:
pricing[провайдер] = { inputPerMillion, outputPerMillion, cachedInputPerMillion?, cacheWritePerMillion? }(доллары за миллион токенов; рассуждения считаются как выход). У всех стандартных моделей цены есть. У своей модели без ценыcreateSpendingLimitsсразу бросает понятную ошибку со списком пар «модель × провайдер»;unpricedModels: "ignore"— явный выбор «такие модели не считать»: их ходы пишутся в журнал с нулевой стоимостью (один раз при создании в журнал сервера попадёт список таких пар). - Цены и журнал. Стоимость записана в самой записи расхода, поэтому смена цен в каталоге и удаление модели прошлый расход не меняют.
- Проверка до хода. Исчерпан лимит — ход не начинается: ничего не записывается, модель не вызывается, сервер отвечает
429 {"error":{"kind":"spending_limit_reached","scope","period","resetsAt"}}(иtimeZone, если пояс задан). Это касается отправки, повтора, переделки и правки. - Ход, который уже идёт, доводится до конца. Один ход может выйти за бюджет; следующий уже не начнётся. Проверка не резервирует деньги, поэтому несколько ходов, начатых одновременно, могут вместе выйти за бюджет.
- Предупреждение. С
warnAt(80%) чат работает, аGET /api/chat/:id/spendingотдаётwarning. - Устойчивость. Расход лежит в базе (SQLite, PostgreSQL или память): перезапуск сервера его не обнуляет, параллельные ходы складываются точно. Журнал только дописывается и переживает удаление чата.
- Расход «примерно». «Стоп» или сбой посреди шага, шаг без чисел от провайдера и ответ, оборванный перезапуском сервера, тоже
стоят денег: такие шаги прикидываются по длине текста, а запись получает
estimated: true. Бесплатных ответов не бывает.
Что видит человек
В браузере <AgentChat spendingLimits /> читает GET /api/chat/:id/spending и показывает надпись рядом с полем ввода
(SpendingNotice в скине; в скине Тандема он есть): «Использовано 80% лимита на месяц» и «Лимит на месяц исчерпан, обновится 1 ноября».
Когда лимит исчерпан, поле ввода неактивно; отправленный в этот момент текст (гонка вкладок) возвращается в поле.
Дата обновления показывается по поясу лимитов. Исчерпание снимается само в момент обновления. Надписи — spendingWarning и spendingExhausted в словаре.
Суммы в долларах браузеру не отдаются: только доля и дата обновления.
Читать расход в приложении
const { user, chat } = await limits.getSpending({ userId, chatId });
// user: { limitUsd, spentUsd, remainingUsd, share, state, period, periodStart, resetsAt }; timeZone — если заданДля страницы «тариф» и своих проверок. chat есть, если передан chatId; limitUsd равен null, если лимита на эту область нет.
Расход чата считается только среди записей этого человека (userId).
compactNow (ручное сжатие истории) лимит не проверяет: сжатие остаётся доступным при исчерпанном лимите, его стоимость
всё равно записывается в расход.
Проверка
Демо (apps/demo): DEMO_SPENDING_LIMIT_USD=0.0005 pnpm dev включает маленький бюджет на каждого человека
(DEMO_SPENDING_PERIOD — month, day или none). Браузерный сценарий — apps/demo/e2e/spending-limits.spec.ts,
живая проверка на настоящей модели — packages/limits/tests/live (pnpm test:live).
