@ykurilov/agent-chat-limits 0.0.0-stage → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +742 -0
- package/LICENSE +21 -0
- package/README.md +180 -2
- package/dist/index.js +841 -0
- package/dist/index.js.map +1 -0
- package/dist/types/create-spending-limits.d.ts +221 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/limit-state.d.ts +16 -0
- package/dist/types/message-windows.d.ts +80 -0
- package/dist/types/spending-period.d.ts +21 -0
- package/dist/types/spending-state.d.ts +11 -0
- package/dist/types/spending-windows.d.ts +68 -0
- package/dist/types/window-reading.d.ts +63 -0
- package/package.json +41 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yury Kurilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,181 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ykurilov/agent-chat-limits
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Лимиты расходов для чата с агентом: бюджет в долларах на человека, на чат и на весь сервис — в любых окнах (сутки, неделя,
|
|
4
|
+
месяц, N часов от первого запроса). Отдельное расширение:
|
|
5
|
+
приложению без лимитов пакет не нужен. Ядро записывает в журнал расхода токены каждого ответа и его стоимость в долларах
|
|
6
|
+
(токены × цены из профиля модели у того провайдера, который ответил, на момент хода). Этот пакет только складывает готовые суммы.
|
|
7
|
+
|
|
8
|
+
## Подключение
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createSpendingLimits } from "@ykurilov/agent-chat-limits";
|
|
12
|
+
|
|
13
|
+
const limits = createSpendingLimits({
|
|
14
|
+
storage, // то же хранилище, что у чата: журнал расхода лежит в нём
|
|
15
|
+
catalog, // тот же каталог моделей, что у чата: из него берутся цены
|
|
16
|
+
budget: async ({ userId, chatId }) => ({ user: planOf(userId).monthlyUsd, chat: 2 }), // хоть одно из двух
|
|
17
|
+
// period: "month" (по умолчанию) | "week" | "day" | "none" — или { user: "month", chat: "day" }, если у областей разный срок;
|
|
18
|
+
// неделя здесь всегда с понедельника, другой первый день — окно с unit: "week" и weekStartsOn (ниже)
|
|
19
|
+
// timeZone: "Europe/Moscow" — сутки, недели и месяцы по этому поясу; без него — по UTC
|
|
20
|
+
// warnAt: 0.8 — с какой доли показывать предупреждение
|
|
21
|
+
// unpricedModels: "reject" (по умолчанию) | "ignore"
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const handler = createChatHandler({ /* … */, limits });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Окна лимитов
|
|
28
|
+
|
|
29
|
+
Вместо `budget` и `period` можно задать любой набор окон. Каждое окно — своя область, свой срок и свой лимит.
|
|
30
|
+
Ход начинается, только если не исчерпано ни одно окно.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const limits = createSpendingLimits({
|
|
34
|
+
storage, catalog,
|
|
35
|
+
timeZone: "Europe/Moscow",
|
|
36
|
+
windows: [
|
|
37
|
+
{ id: "5h", scope: "user", kind: "rolling", hours: 5, limitUsd: 0.3, carryOverrun: true },
|
|
38
|
+
{ id: "week", scope: "user", kind: "calendar", unit: "week", weekStartsOn: 1, limitUsd: 1 },
|
|
39
|
+
{ id: "service-day", scope: "service", kind: "calendar", unit: "day", limitUsd: 20 },
|
|
40
|
+
],
|
|
41
|
+
turn: { minRemainingUsd: 0.03, maxCostUsd: 0.1, maxToolCalls: 40 },
|
|
42
|
+
onThreshold: (event) => notifyAdmin(event), // 80% и 100% по умолчанию, см. thresholds
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **Область** (`scope`): `user` — все чаты человека, `chat` — один чат, `service` — весь сервис, все люди вместе.
|
|
47
|
+
- **Срок** (`kind`):
|
|
48
|
+
- `calendar` — сутки, неделя или месяц (`unit`) по поясу окна (`timeZone`, иначе пояс лимитов, иначе UTC). Неделя
|
|
49
|
+
начинается с `weekStartsOn`: 1 — понедельник (по умолчанию), 7 — воскресенье.
|
|
50
|
+
- `rolling` — `hours` часов от первого запроса. Окно кончилось — следующее начнётся со следующего запроса, а не сразу.
|
|
51
|
+
- `total` — на всё время, без обнуления.
|
|
52
|
+
- **Лимит** (`limitUsd`): число или функция `({ userId, chatId }) => число | null`. Функция зовётся на каждую проверку, так
|
|
53
|
+
что смена тарифа действует сразу; `null` — у этого человека окна нет.
|
|
54
|
+
- **Перерасход.** Начатый ход доделывается, даже если вышел за лимит. С `carryOverrun: true` то, что вышло сверх лимита,
|
|
55
|
+
переносится в начало следующего окна: в окне 5 часов с лимитом $0.30 ход на $0.45 оставит $0.15 долга на следующее окно.
|
|
56
|
+
Долг больше лимита гасится по окнам: первый запрос (даже отклонённый) открывает окно с долгом, в следующее идёт остаток.
|
|
57
|
+
- **Хранилище.** Скользящим окнам, ручному сбросу, личным настройкам и порогам нужны методы состояния хранилища
|
|
58
|
+
(`getSpendingLimitState`, `saveSpendingLimitState`). В хранилищах SDK (память, SQLite, PostgreSQL) они есть. Нет методов —
|
|
59
|
+
ошибка при создании.
|
|
60
|
+
- Окна и `budget` вместе задать нельзя: ошибка при создании.
|
|
61
|
+
|
|
62
|
+
### Лимит сообщений
|
|
63
|
+
|
|
64
|
+
Окно считает доллары (`limitUsd`) или сообщения человека (`limitMessages`) — ровно одно из двух; окна денег и сообщений
|
|
65
|
+
можно смешивать.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
windows: [{ id: "day-messages", scope: "user", kind: "calendar", unit: "day", limitMessages: 20 }],
|
|
69
|
+
exemptUsers: ({ userId }) => staff.has(userId), // без лимитов: все окна, и денежные
|
|
70
|
+
onMessageThreshold: (event) => notify(event.userId, `Осталось ${event.remaining}`),
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- Считаются отправка и правка. Переделка, повтор, служебные сообщения, ответы на подтверждения и виджеты, импорт и
|
|
74
|
+
песочница — нет. Место берётся до записи сообщения; не записалось — место возвращается.
|
|
75
|
+
- Счёт лежит в состоянии лимитов: удаление чата его не сбрасывает. Два сообщения на последнее место — проходит одно.
|
|
76
|
+
- Отказ — 429 `spending_limit_reached` с `unit: "messages"`; экран: «Осталось 1 сообщение», «Лимит сообщений исчерпан».
|
|
77
|
+
- `exemptUsers` снимает окна, но не потолки хода (`maxCostUsd`, `maxToolCalls`). Его сбой — `logError`, ход не начинается.
|
|
78
|
+
- Пороги — `onMessageThreshold({ windowId, threshold, share, used, limit, remaining, windowStart, resetsAt, userId, chatId? })`,
|
|
79
|
+
по разу на порог в окне; `onThreshold` сообщений не видит.
|
|
80
|
+
- Личный лимит, блокировка и сброс — как у денег (`setUserSettings`, `resetWindow`).
|
|
81
|
+
|
|
82
|
+
### Правила хода (`turn`)
|
|
83
|
+
|
|
84
|
+
- `minRemainingUsd` — запас на старт: новый ход начинается, только если в каждом окне осталось не меньше. Отказ —
|
|
85
|
+
429 с `reason: "reserve"`, а `GET …/spending` отдаёт `exhausted` с той же причиной.
|
|
86
|
+
- `maxCostUsd` и `maxToolCalls` — потолки одного хода. Дошёл ход до потолка — следующий шаг идёт без инструментов
|
|
87
|
+
и с просьбой подвести итог. Ответ кончается итогом, а не обрывом.
|
|
88
|
+
- `overrun`: `"finish"` (по умолчанию) — ход доделывается, перерасход остаётся в окне или переносится; `"stop"` — потолок
|
|
89
|
+
денег хода не больше остатка окна, ход на остатке заканчивается итогом.
|
|
90
|
+
|
|
91
|
+
### Человек: свой лимит, блокировка, сброс
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
await limits.setUserSettings("anna", { limits: { week: 5, "5h": null } }); // свой лимит; null — без лимита
|
|
95
|
+
await limits.setUserSettings("anna", { limits: { week: "default" } }); // снова общий
|
|
96
|
+
await limits.setUserSettings("anna", { blocked: true }); // ходы не начинаются
|
|
97
|
+
await limits.getUserSettings("anna"); // { blocked, limits }
|
|
98
|
+
await limits.resetWindow({ windowId: "week", userId: "anna" }); // расход окна до этого мига не считается
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Свои лимиты действуют на окна `user` и `chat`; у окна `service` своего лимита у человека нет. С `budget` своих лимитов нет
|
|
102
|
+
(ошибка: лимит человека — в самой функции `budget`), а блокировка работает. После ручного сброса пороги окна шлются снова. Блокировка — отказ 429 с
|
|
103
|
+
`reason: "blocked"`. Ручной сброс окна чата — с `chatId`, окна сервиса — без `userId`. Скользящее окно после сброса
|
|
104
|
+
закрывается: новое начнётся с первого запроса.
|
|
105
|
+
|
|
106
|
+
### Пороги
|
|
107
|
+
|
|
108
|
+
`onThreshold(event)` зовётся один раз на порог в каждом окне: `{ windowId, scope, period, hours?, threshold, share, spentUsd,
|
|
109
|
+
limitUsd, windowStart, resetsAt, userId, chatId? }`. Пороги — `thresholds` (по умолчанию `[0.8, 1]`). Проверка идёт после
|
|
110
|
+
записи расхода (`afterSpending`): ответа агента и картинок. Ход обработчик не ждёт, ошибка уходит в `logError`. Две копии
|
|
111
|
+
сервера одно событие дважды не пришлют: отметка о пороге пишется по версии.
|
|
112
|
+
|
|
113
|
+
### Что видит браузер
|
|
114
|
+
|
|
115
|
+
`GET …/spending` отдаёт самое тесное окно (`windowId`, `period`, `hours`, `share`, `resetsAt`, `reason`) и все окна
|
|
116
|
+
списком `windows` — только доли и время сброса, без долларов. Надписи: «Использовано 85% лимита на 5 ч», «Лимит сервиса
|
|
117
|
+
на день исчерпан», «Лимита на 5 ч не хватит на новый ответ, обновится 7 октября в 17:00», «Новые ответы для вас приостановлены».
|
|
118
|
+
|
|
119
|
+
Доллары по каждому окну — в `getSpending`: `windows: [{ id, scope, period, hours?, limitUsd, spentUsd, remainingUsd, share,
|
|
120
|
+
state, start, resetsAt, carriedUsd }]` и `blocked`.
|
|
121
|
+
|
|
122
|
+
## Бюджет (прежний способ)
|
|
123
|
+
|
|
124
|
+
`budget` — функция приложения: какой лимит у этого человека и этого чата. Вернуть можно `{ user }`, `{ chat }` или оба;
|
|
125
|
+
`undefined` в поле — этого лимита нет. Тарифы, промокоды и бонусы остаются в приложении.
|
|
126
|
+
|
|
127
|
+
- **Период.** `month` — календарный месяц, `day` — сутки, `none` — без обнуления. Границы считаются по UTC,
|
|
128
|
+
а с `timeZone` (пояс IANA, например `"Europe/Moscow"`) — по полуночи этого пояса: без него московский суточный лимит
|
|
129
|
+
обнулялся бы в 03:00. Переход на летнее время учтён: сутки бывают 23 и 25 часов. Тот же пояс — запасной пояс
|
|
130
|
+
сегодняшней даты агента (`agent.timeZone` задавать не нужно; если задан, должен совпадать). Пояс не из базы IANA —
|
|
131
|
+
ошибка при создании.
|
|
132
|
+
Расход за прошлый период в журнале остаётся, просто в новый период не входит. Ход и сжатие относятся к тому периоду,
|
|
133
|
+
в котором они **начались**: ход, начатый 31-го в 23:59, целиком идёт в старый месяц.
|
|
134
|
+
- **Что входит в расход.** Ответы агента (со всеми шагами хода, включая вызовы инструментов) и фоновое сжатие истории,
|
|
135
|
+
в том числе сжатие, которое не удалось, но уже было оплачено.
|
|
136
|
+
Веб-инструменты (`web-tools`) входят, если им передан `spending` с ценой вызова (README пакета): расход `web_tools`.
|
|
137
|
+
- **Одновременные ходы.** Проверка не резервирует деньги, поэтому у человека одновременно идут не больше трёх ответов
|
|
138
|
+
(`maxConcurrentTurnsPerUser` в `createChatHandler`): перерасход — не больше трёх ходов сверх лимита.
|
|
139
|
+
- **Цены.** Лежат в профиле модели: `pricing[провайдер] = { inputPerMillion, outputPerMillion, cachedInputPerMillion?, cacheWritePerMillion? }`
|
|
140
|
+
(доллары за миллион токенов; рассуждения считаются как выход). У всех стандартных моделей цены есть. У своей модели без цены
|
|
141
|
+
`createSpendingLimits` сразу бросает понятную ошибку со списком пар «модель × провайдер»; `unpricedModels: "ignore"` — явный
|
|
142
|
+
выбор «такие модели не считать»: их ходы пишутся в журнал с нулевой стоимостью (один раз при создании в журнал сервера
|
|
143
|
+
попадёт список таких пар).
|
|
144
|
+
- **Цены и журнал.** Стоимость записана в самой записи расхода, поэтому смена цен в каталоге и удаление модели прошлый
|
|
145
|
+
расход не меняют.
|
|
146
|
+
- **Проверка до хода.** Исчерпан лимит — ход не начинается: ничего не записывается, модель не вызывается, сервер отвечает
|
|
147
|
+
`429 {"error":{"kind":"spending_limit_reached","scope","period","resetsAt"}}` (и `timeZone`, если пояс задан). Это касается отправки, повтора, переделки и правки.
|
|
148
|
+
- **Ход, который уже идёт, доводится до конца.** Один ход может выйти за бюджет; следующий уже не начнётся. Проверка не
|
|
149
|
+
резервирует деньги, поэтому несколько ходов, начатых одновременно, могут вместе выйти за бюджет.
|
|
150
|
+
- **Предупреждение.** С `warnAt` (80%) чат работает, а `GET /api/chat/:id/spending` отдаёт `warning`.
|
|
151
|
+
- **Устойчивость.** Расход лежит в базе (SQLite, PostgreSQL или память): перезапуск сервера его не обнуляет, параллельные ходы
|
|
152
|
+
складываются точно. Журнал только дописывается и переживает удаление чата.
|
|
153
|
+
- **Расход «примерно».** «Стоп» или сбой посреди шага, шаг без чисел от провайдера и ответ, оборванный перезапуском сервера, тоже
|
|
154
|
+
стоят денег: такие шаги прикидываются по длине текста, а запись получает `estimated: true`. Бесплатных ответов не бывает.
|
|
155
|
+
|
|
156
|
+
## Что видит человек
|
|
157
|
+
|
|
158
|
+
В браузере `<AgentChat spendingLimits />` читает `GET /api/chat/:id/spending` и показывает надпись рядом с полем ввода
|
|
159
|
+
(`SpendingNotice` в скине; в скине Тандема он есть): «Использовано 80% лимита на месяц» и «Лимит на месяц исчерпан, обновится 1 ноября».
|
|
160
|
+
Когда лимит исчерпан, поле ввода неактивно; отправленный в этот момент текст (гонка вкладок) возвращается в поле.
|
|
161
|
+
Дата обновления показывается по поясу лимитов. Исчерпание снимается само в момент обновления. Надписи — `spendingWarning` и `spendingExhausted` в словаре.
|
|
162
|
+
Суммы в долларах браузеру не отдаются: только доля и дата обновления.
|
|
163
|
+
|
|
164
|
+
## Читать расход в приложении
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const { user, chat } = await limits.getSpending({ userId, chatId });
|
|
168
|
+
// user: { limitUsd, spentUsd, remainingUsd, share, state, period, periodStart, resetsAt }; timeZone — если задан
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Для страницы «тариф» и своих проверок. `chat` есть, если передан `chatId`; `limitUsd` равен `null`, если лимита на эту область нет.
|
|
172
|
+
Расход чата считается только среди записей этого человека (`userId`).
|
|
173
|
+
|
|
174
|
+
`compactNow` (ручное сжатие истории) лимит **не проверяет**: сжатие остаётся доступным при исчерпанном лимите, его стоимость
|
|
175
|
+
всё равно записывается в расход.
|
|
176
|
+
|
|
177
|
+
## Проверка
|
|
178
|
+
|
|
179
|
+
Демо (`apps/demo`): `DEMO_SPENDING_LIMIT_USD=0.0005 pnpm dev` включает маленький бюджет на каждого человека
|
|
180
|
+
(`DEMO_SPENDING_PERIOD` — `month`, `day` или `none`). Браузерный сценарий — `apps/demo/e2e/spending-limits.spec.ts`,
|
|
181
|
+
живая проверка на настоящей модели — `packages/limits/tests/live` (`pnpm test:live`).
|