npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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).