@ykurilov/agent-chat-limits 0.0.0-stage → 0.56.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 +749 -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/dist/index.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
// src/create-spending-limits.ts
|
|
2
|
+
import { SpendingLimitReachedError, resolveTimeZone as resolveTimeZone2 } from "@ykurilov/agent-chat-core";
|
|
3
|
+
|
|
4
|
+
// src/limit-state.ts
|
|
5
|
+
var UPDATE_ATTEMPTS = 50;
|
|
6
|
+
var MAX_RETRY_PAUSE_MS = 32;
|
|
7
|
+
function retryPause(attempt) {
|
|
8
|
+
const ceiling = Math.min(MAX_RETRY_PAUSE_MS, 2 ** attempt);
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, Math.random() * ceiling));
|
|
10
|
+
}
|
|
11
|
+
function limitStateStore(storage) {
|
|
12
|
+
const get = storage.getSpendingLimitState?.bind(storage);
|
|
13
|
+
const save = storage.saveSpendingLimitState?.bind(storage);
|
|
14
|
+
if (!get || !save) return null;
|
|
15
|
+
const queues = /* @__PURE__ */ new Map();
|
|
16
|
+
function inTurn(key, work) {
|
|
17
|
+
const result = (queues.get(key) ?? Promise.resolve()).then(work);
|
|
18
|
+
const tail = result.catch(() => void 0);
|
|
19
|
+
queues.set(key, tail);
|
|
20
|
+
void tail.then(() => {
|
|
21
|
+
if (queues.get(key) === tail) queues.delete(key);
|
|
22
|
+
});
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async read(key) {
|
|
27
|
+
const record = await get(key);
|
|
28
|
+
return record ? JSON.parse(record.value) : null;
|
|
29
|
+
},
|
|
30
|
+
update(key, change) {
|
|
31
|
+
return inTurn(key, async () => {
|
|
32
|
+
for (let attempt = 0; attempt < UPDATE_ATTEMPTS; attempt++) {
|
|
33
|
+
if (attempt > 0) await retryPause(attempt);
|
|
34
|
+
const record = await get(key);
|
|
35
|
+
const current = record ? JSON.parse(record.value) : null;
|
|
36
|
+
const next = change(current);
|
|
37
|
+
if (next === null) return { value: current, written: false };
|
|
38
|
+
if (await save(key, JSON.stringify(next), record?.version ?? null)) return { value: next, written: true };
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`\u0421\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u043B\u0438\u043C\u0438\u0442\u043E\u0432 \xAB${key}\xBB: \u0437\u0430\u043F\u0438\u0441\u044C \u0432\u0441\u0451 \u0432\u0440\u0435\u043C\u044F \u043C\u0435\u043D\u044F\u044E\u0442 \u0434\u0440\u0443\u0433\u0438\u0435 \u0445\u043E\u0434\u044B`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/spending-state.ts
|
|
47
|
+
var PRECISION = 1e9;
|
|
48
|
+
var round = (amount) => Math.round(amount * PRECISION) / PRECISION;
|
|
49
|
+
function spendingState(input) {
|
|
50
|
+
const spent = round(input.spentUsd);
|
|
51
|
+
const limit = round(input.limitUsd);
|
|
52
|
+
const share = limit > 0 ? round(spent / limit) : 1;
|
|
53
|
+
if (spent >= limit) return { state: "exhausted", share };
|
|
54
|
+
if (share >= input.warnAt) return { state: "warning", share };
|
|
55
|
+
return { state: "ok", share };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/spending-period.ts
|
|
59
|
+
function calendarBounds(unit, now, timeZone = "UTC", weekStartsOn = 1) {
|
|
60
|
+
const { year, month, day } = localDate(now, timeZone);
|
|
61
|
+
if (unit === "month") return { start: localMidnight(year, month, 1, timeZone), end: localMidnight(year, month + 1, 1, timeZone) };
|
|
62
|
+
if (unit === "day") return { start: localMidnight(year, month, day, timeZone), end: localMidnight(year, month, day + 1, timeZone) };
|
|
63
|
+
const weekday = new Date(Date.UTC(year, month, day)).getUTCDay() || 7;
|
|
64
|
+
const startDay = day - (weekday - weekStartsOn + 7) % 7;
|
|
65
|
+
return { start: localMidnight(year, month, startDay, timeZone), end: localMidnight(year, month, startDay + 7, timeZone) };
|
|
66
|
+
}
|
|
67
|
+
var formatters = /* @__PURE__ */ new Map();
|
|
68
|
+
function formatterOf(timeZone) {
|
|
69
|
+
let formatter = formatters.get(timeZone);
|
|
70
|
+
if (!formatter) {
|
|
71
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
72
|
+
timeZone,
|
|
73
|
+
hourCycle: "h23",
|
|
74
|
+
year: "numeric",
|
|
75
|
+
month: "numeric",
|
|
76
|
+
day: "numeric",
|
|
77
|
+
hour: "numeric",
|
|
78
|
+
minute: "numeric",
|
|
79
|
+
second: "numeric"
|
|
80
|
+
});
|
|
81
|
+
formatters.set(timeZone, formatter);
|
|
82
|
+
}
|
|
83
|
+
return formatter;
|
|
84
|
+
}
|
|
85
|
+
function wallClock(moment, timeZone) {
|
|
86
|
+
const parts = Object.fromEntries(formatterOf(timeZone).formatToParts(moment).map((part) => [part.type, Number(part.value)]));
|
|
87
|
+
return { year: parts.year, month: parts.month - 1, day: parts.day, hour: parts.hour, minute: parts.minute, second: parts.second };
|
|
88
|
+
}
|
|
89
|
+
function localDate(moment, timeZone) {
|
|
90
|
+
const { year, month, day } = wallClock(moment, timeZone);
|
|
91
|
+
return { year, month, day };
|
|
92
|
+
}
|
|
93
|
+
function offsetMs(epochMs, timeZone) {
|
|
94
|
+
const wall = wallClock(new Date(epochMs), timeZone);
|
|
95
|
+
const wallAsUtc = Date.UTC(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second);
|
|
96
|
+
return wallAsUtc - Math.floor(epochMs / 1e3) * 1e3;
|
|
97
|
+
}
|
|
98
|
+
var DAY_MS = 864e5;
|
|
99
|
+
function localMidnight(year, month, day, timeZone) {
|
|
100
|
+
const wallMidnight = Date.UTC(year, month, day);
|
|
101
|
+
const target = new Date(wallMidnight);
|
|
102
|
+
const candidates = [offsetMs(wallMidnight - DAY_MS, timeZone), offsetMs(wallMidnight + DAY_MS, timeZone)].map((offset) => wallMidnight - offset).filter((moment) => {
|
|
103
|
+
const wall = wallClock(new Date(moment), timeZone);
|
|
104
|
+
return wall.year === target.getUTCFullYear() && wall.month === target.getUTCMonth() && wall.day === target.getUTCDate();
|
|
105
|
+
});
|
|
106
|
+
return new Date(candidates.length > 0 ? Math.min(...candidates) : wallMidnight - offsetMs(wallMidnight, timeZone)).toISOString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/window-reading.ts
|
|
110
|
+
var HOUR_MS = 36e5;
|
|
111
|
+
function windowStateKey(window, subject) {
|
|
112
|
+
if (window.scope === "service") return `window:service:${window.id}`;
|
|
113
|
+
if (window.scope === "user") return `window:user:${subject.userId}:${window.id}`;
|
|
114
|
+
return subject.chatId === void 0 ? null : `window:chat:${subject.chatId}:${window.id}`;
|
|
115
|
+
}
|
|
116
|
+
function usageFilterOf(window, subject) {
|
|
117
|
+
if (window.scope === "service") return {};
|
|
118
|
+
if (window.scope === "user") return { ownerId: subject.userId };
|
|
119
|
+
return { ownerId: subject.userId, chatId: subject.chatId };
|
|
120
|
+
}
|
|
121
|
+
function currentSpan(window, saved, now, open) {
|
|
122
|
+
if (window.kind === "total") return { start: null, end: null, active: true };
|
|
123
|
+
if (window.kind === "calendar") {
|
|
124
|
+
const bounds = calendarBounds(window.unit, now, window.timeZone, window.weekStartsOn);
|
|
125
|
+
return { start: bounds.start, end: bounds.end, active: true };
|
|
126
|
+
}
|
|
127
|
+
const hoursMs = window.hours * HOUR_MS;
|
|
128
|
+
if (saved?.start && now.getTime() < Date.parse(saved.start) + hoursMs) {
|
|
129
|
+
return { start: saved.start, end: new Date(Date.parse(saved.start) + hoursMs).toISOString(), active: true };
|
|
130
|
+
}
|
|
131
|
+
if (open) return { start: now.toISOString(), end: new Date(now.getTime() + hoursMs).toISOString(), active: true };
|
|
132
|
+
return { start: null, end: null, active: false };
|
|
133
|
+
}
|
|
134
|
+
async function readWindow(input) {
|
|
135
|
+
const { window, subject, now, store } = input;
|
|
136
|
+
const stateKey = windowStateKey(window, subject);
|
|
137
|
+
if (stateKey === null) return null;
|
|
138
|
+
const filter = usageFilterOf(window, subject);
|
|
139
|
+
const saved = store ? await store.read(stateKey) : null;
|
|
140
|
+
const span = currentSpan(window, saved, now, input.open);
|
|
141
|
+
const sameWindow = saved !== null && span.active && saved.start === span.start;
|
|
142
|
+
const limitUsd = input.limitUsd ?? null;
|
|
143
|
+
let carriedUsd = 0;
|
|
144
|
+
if (sameWindow) carriedUsd = saved.carriedUsd;
|
|
145
|
+
else if (window.carryOverrun && limitUsd !== null) carriedUsd = await carryInto({ ...input, filter, saved, spanStart: span.start, limitUsd });
|
|
146
|
+
const since = latest(span.start, sameWindow ? saved.resetAt : void 0);
|
|
147
|
+
const recorded = span.active ? await input.sumSince(filter, since) : 0;
|
|
148
|
+
const spentUsd = recorded + carriedUsd + Math.max(0, input.pendingUsd ?? 0);
|
|
149
|
+
const judged = limitUsd === null ? { state: "ok", share: null } : spendingState({ spentUsd, limitUsd, warnAt: input.warnAt });
|
|
150
|
+
const changed = !sameWindow && span.active && store !== null && (window.kind === "rolling" || carriedUsd > 0 || saved !== null);
|
|
151
|
+
return {
|
|
152
|
+
window,
|
|
153
|
+
stateKey,
|
|
154
|
+
limitUsd,
|
|
155
|
+
start: span.start,
|
|
156
|
+
resetsAt: span.end,
|
|
157
|
+
waiting: !span.active,
|
|
158
|
+
spentUsd,
|
|
159
|
+
carriedUsd,
|
|
160
|
+
state: judged.state,
|
|
161
|
+
share: judged.share,
|
|
162
|
+
nextState: changed ? { start: span.start, carriedUsd, notified: [] } : null,
|
|
163
|
+
savedStart: saved === null ? void 0 : saved.start
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async function carryInto(input) {
|
|
167
|
+
const { window, saved, filter, limitUsd } = input;
|
|
168
|
+
if (window.kind === "total") return 0;
|
|
169
|
+
if (window.kind === "rolling") {
|
|
170
|
+
if (!saved?.start) return 0;
|
|
171
|
+
const end = new Date(Date.parse(saved.start) + window.hours * HOUR_MS).toISOString();
|
|
172
|
+
const spent2 = await spentBetween(input, filter, latest(saved.start, saved.resetAt), end) + saved.carriedUsd;
|
|
173
|
+
return Math.max(0, spent2 - limitUsd);
|
|
174
|
+
}
|
|
175
|
+
const start = input.spanStart;
|
|
176
|
+
const previous = calendarBounds(window.unit, new Date(Date.parse(start) - 1), window.timeZone, window.weekStartsOn);
|
|
177
|
+
const known = saved !== null && saved.start === previous.start;
|
|
178
|
+
const since = latest(previous.start, known ? saved.resetAt : void 0);
|
|
179
|
+
const spent = await spentBetween(input, filter, since, start) + (known ? saved.carriedUsd : 0);
|
|
180
|
+
return Math.max(0, spent - limitUsd);
|
|
181
|
+
}
|
|
182
|
+
async function spentBetween(input, filter, since, until) {
|
|
183
|
+
if (since !== null && since >= until) return 0;
|
|
184
|
+
const [fromStart, fromEnd] = await Promise.all([input.sumSince(filter, since), input.sumSince(filter, until)]);
|
|
185
|
+
return fromStart - fromEnd;
|
|
186
|
+
}
|
|
187
|
+
function latest(start, other) {
|
|
188
|
+
if (other === void 0) return start;
|
|
189
|
+
if (start === null) return other;
|
|
190
|
+
return other > start ? other : start;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/message-windows.ts
|
|
194
|
+
function usedIn(saved, start) {
|
|
195
|
+
return saved !== null && saved.start === start ? saved.messages ?? 0 : 0;
|
|
196
|
+
}
|
|
197
|
+
async function readMessageWindow(input) {
|
|
198
|
+
const { window, limit } = input;
|
|
199
|
+
const stateKey = windowStateKey(window, input.subject);
|
|
200
|
+
if (stateKey === null) return null;
|
|
201
|
+
const saved = await input.store.read(stateKey);
|
|
202
|
+
const span = currentSpan(window, saved, input.now, false);
|
|
203
|
+
const used = span.active ? usedIn(saved, span.start) : 0;
|
|
204
|
+
const judged = spendingState({ spentUsd: used, limitUsd: limit, warnAt: input.warnAt });
|
|
205
|
+
return { window, limit, used, start: span.start, resetsAt: span.end, state: judged.state, share: judged.share };
|
|
206
|
+
}
|
|
207
|
+
async function takeMessageSlots(input) {
|
|
208
|
+
const { store, now } = input;
|
|
209
|
+
const taken = [];
|
|
210
|
+
const refused = [];
|
|
211
|
+
const crossed = [];
|
|
212
|
+
try {
|
|
213
|
+
for (const { window, limit } of input.windows) {
|
|
214
|
+
const stateKey = windowStateKey(window, input.subject);
|
|
215
|
+
if (stateKey === null) continue;
|
|
216
|
+
if (refused.length > 0) {
|
|
217
|
+
const saved = await store.read(stateKey);
|
|
218
|
+
const span = currentSpan(window, saved, now, true);
|
|
219
|
+
if (usedIn(saved, span.start) >= limit) refused.push({ window, resetsAt: span.end });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
let outcome = { end: null, full: false, used: 0 };
|
|
223
|
+
await store.update(stateKey, (current) => {
|
|
224
|
+
const span = currentSpan(window, current, now, true);
|
|
225
|
+
const used = usedIn(current, span.start);
|
|
226
|
+
outcome = { end: span.end, full: used >= limit, used: used + 1 };
|
|
227
|
+
if (outcome.full) return null;
|
|
228
|
+
const sameWindow = current !== null && current.start === span.start;
|
|
229
|
+
const base = sameWindow ? current : { start: span.start, carriedUsd: 0, notified: [] };
|
|
230
|
+
const count = sameWindow && current.count || crypto.randomUUID();
|
|
231
|
+
const share = limit === 0 ? 1 : (used + 1) / limit;
|
|
232
|
+
const reached = input.thresholds.filter((threshold) => share >= threshold && !base.notified.includes(threshold));
|
|
233
|
+
outcome.slot = { stateKey, start: span.start, count, openedWindow: window.kind === "rolling" && !sameWindow, crossed: reached };
|
|
234
|
+
return { ...base, start: span.start, messages: used + 1, count, notified: [...base.notified, ...reached] };
|
|
235
|
+
});
|
|
236
|
+
if (outcome.full) {
|
|
237
|
+
refused.push({ window, resetsAt: outcome.end });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const slot = outcome.slot;
|
|
241
|
+
taken.push(slot);
|
|
242
|
+
for (const threshold of slot.crossed) crossed.push({ window, threshold, used: outcome.used, limit, start: slot.start, resetsAt: outcome.end });
|
|
243
|
+
}
|
|
244
|
+
} catch (failure) {
|
|
245
|
+
await giveBackMessageSlots(store, taken, { unmarkThresholds: true });
|
|
246
|
+
throw failure;
|
|
247
|
+
}
|
|
248
|
+
if (refused.length > 0) {
|
|
249
|
+
await giveBackMessageSlots(store, taken, { unmarkThresholds: true });
|
|
250
|
+
return { taken: [], refused, crossed: [] };
|
|
251
|
+
}
|
|
252
|
+
return { taken, refused, crossed };
|
|
253
|
+
}
|
|
254
|
+
async function giveBackMessageSlots(store, taken, options = {}) {
|
|
255
|
+
await Promise.all(
|
|
256
|
+
taken.map(
|
|
257
|
+
(slot) => store.update(slot.stateKey, (current) => {
|
|
258
|
+
if (current === null || current.start !== slot.start || current.count !== slot.count || !current.messages) return null;
|
|
259
|
+
const messages = current.messages - 1;
|
|
260
|
+
if (slot.openedWindow && messages === 0) return { carriedUsd: 0, notified: [], start: null };
|
|
261
|
+
const notified = options.unmarkThresholds ? current.notified.filter((threshold) => !slot.crossed.includes(threshold)) : current.notified;
|
|
262
|
+
return { ...current, messages, notified };
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/spending-windows.ts
|
|
269
|
+
import { resolveTimeZone } from "@ykurilov/agent-chat-core";
|
|
270
|
+
var SCOPES = ["user", "chat", "service"];
|
|
271
|
+
var UNITS = ["day", "week", "month"];
|
|
272
|
+
function normalizeWindows(configs, defaultTimeZone) {
|
|
273
|
+
const seen = /* @__PURE__ */ new Set();
|
|
274
|
+
return configs.map((config) => {
|
|
275
|
+
const where = `\u041E\u043A\u043D\u043E \u043B\u0438\u043C\u0438\u0442\u0430 \xAB${String(config.id)}\xBB`;
|
|
276
|
+
if (typeof config.id !== "string" || config.id === "") throw new Error("\u041E\u043A\u043D\u043E \u043B\u0438\u043C\u0438\u0442\u0430: \u043D\u0443\u0436\u043D\u043E \u043D\u0435\u043F\u0443\u0441\u0442\u043E\u0435 \u0438\u043C\u044F id");
|
|
277
|
+
if (seen.has(config.id)) throw new Error(`${where}: \u0438\u043C\u044F \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u0435\u0442\u0441\u044F`);
|
|
278
|
+
seen.add(config.id);
|
|
279
|
+
if (!SCOPES.includes(config.scope)) throw new Error(`${where}: scope \u2014 user, chat \u0438\u043B\u0438 service, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(config.scope)}`);
|
|
280
|
+
const hasUsd = config.limitUsd !== void 0;
|
|
281
|
+
if (hasUsd === (config.limitMessages !== void 0)) throw new Error(`${where}: \u043D\u0443\u0436\u0435\u043D \u0440\u043E\u0432\u043D\u043E \u043E\u0434\u0438\u043D \u043B\u0438\u043C\u0438\u0442 \u2014 limitUsd \u0438\u043B\u0438 limitMessages`);
|
|
282
|
+
const measure = hasUsd ? "usd" : "messages";
|
|
283
|
+
if (measure === "messages" && config.carryOverrun === true) throw new Error(`${where}: carryOverrun \u0431\u044B\u0432\u0430\u0435\u0442 \u0442\u043E\u043B\u044C\u043A\u043E \u0443 \u043E\u043A\u043D\u0430 \u0434\u0435\u043D\u0435\u0433 (limitUsd)`);
|
|
284
|
+
const limit = checkedLimit(hasUsd ? config.limitUsd : config.limitMessages, measure, where);
|
|
285
|
+
const base = { id: config.id, scope: config.scope, carryOverrun: config.carryOverrun === true, measure, limit, weekStartsOn: 1 };
|
|
286
|
+
if (config.kind === "rolling") {
|
|
287
|
+
if (!(Number.isFinite(config.hours) && config.hours > 0)) throw new Error(`${where}: hours \u2014 \u0447\u0438\u0441\u043B\u043E \u0447\u0430\u0441\u043E\u0432 \u0431\u043E\u043B\u044C\u0448\u0435 0, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(config.hours)}`);
|
|
288
|
+
return { ...base, kind: "rolling", hours: config.hours, timeZone: "UTC", period: "rolling" };
|
|
289
|
+
}
|
|
290
|
+
if (config.kind === "total") return { ...base, kind: "total", timeZone: "UTC", period: "none" };
|
|
291
|
+
if (config.kind !== "calendar") throw new Error(`${where}: kind \u2014 calendar, rolling \u0438\u043B\u0438 total, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(config.kind)}`);
|
|
292
|
+
if (!UNITS.includes(config.unit)) throw new Error(`${where}: unit \u2014 day, week \u0438\u043B\u0438 month, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(config.unit)}`);
|
|
293
|
+
const weekStartsOn = config.weekStartsOn ?? 1;
|
|
294
|
+
if (!(Number.isInteger(weekStartsOn) && weekStartsOn >= 1 && weekStartsOn <= 7)) {
|
|
295
|
+
throw new Error(`${where}: weekStartsOn \u2014 \u043E\u0442 1 (\u043F\u043E\u043D\u0435\u0434\u0435\u043B\u044C\u043D\u0438\u043A) \u0434\u043E 7 (\u0432\u043E\u0441\u043A\u0440\u0435\u0441\u0435\u043D\u044C\u0435), \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(weekStartsOn)}`);
|
|
296
|
+
}
|
|
297
|
+
const timeZone = config.timeZone ?? defaultTimeZone ?? "UTC";
|
|
298
|
+
if (resolveTimeZone(timeZone) === void 0) throw new Error(`${where}: \xAB${timeZone}\xBB \u2014 \u043D\u0435 \u0447\u0430\u0441\u043E\u0432\u043E\u0439 \u043F\u043E\u044F\u0441 IANA`);
|
|
299
|
+
return { ...base, kind: "calendar", unit: config.unit, timeZone, weekStartsOn, period: config.unit };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function checkedLimit(limit, measure, where) {
|
|
303
|
+
if (typeof limit === "function") {
|
|
304
|
+
return async (subject) => {
|
|
305
|
+
const amount2 = await limit(subject);
|
|
306
|
+
if (amount2 === void 0 || amount2 === null) return void 0;
|
|
307
|
+
return validLimit(amount2, measure, where);
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const amount = validLimit(limit, measure, where);
|
|
311
|
+
return async () => amount;
|
|
312
|
+
}
|
|
313
|
+
function validLimit(amount, measure, where) {
|
|
314
|
+
if (measure === "messages") {
|
|
315
|
+
if (typeof amount === "number" && Number.isInteger(amount) && amount >= 0) return amount;
|
|
316
|
+
throw new Error(`${where}: limitMessages \u2014 \u0446\u0435\u043B\u043E\u0435 \u043D\u0435\u043E\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u0447\u0438\u0441\u043B\u043E \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0439, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(amount)}`);
|
|
317
|
+
}
|
|
318
|
+
if (typeof amount === "number" && Number.isFinite(amount) && amount >= 0) return amount;
|
|
319
|
+
throw new Error(`${where}: limitUsd \u2014 \u043D\u0435\u043E\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u0447\u0438\u0441\u043B\u043E \u0434\u043E\u043B\u043B\u0430\u0440\u043E\u0432, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(amount)}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/create-spending-limits.ts
|
|
323
|
+
var DEFAULT_PERIOD = "month";
|
|
324
|
+
var DEFAULT_WARN_AT = 0.8;
|
|
325
|
+
var DEFAULT_THRESHOLDS = [0.8, 1];
|
|
326
|
+
var STATE_SEVERITY = { ok: 0, warning: 1, exhausted: 2 };
|
|
327
|
+
function createSpendingLimits(options) {
|
|
328
|
+
const { storage } = options;
|
|
329
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
330
|
+
const logError = options.logError ?? ((message) => console.error(message));
|
|
331
|
+
const warnAt = options.warnAt ?? DEFAULT_WARN_AT;
|
|
332
|
+
if (!(Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1)) {
|
|
333
|
+
throw new Error(`warnAt \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0447\u0438\u0441\u043B\u043E\u043C \u0431\u043E\u043B\u044C\u0448\u0435 0 \u0438 \u043C\u0435\u043D\u044C\u0448\u0435 1, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(warnAt)}`);
|
|
334
|
+
}
|
|
335
|
+
const timeZone = checkedTimeZone(options.timeZone);
|
|
336
|
+
const zone = timeZone === void 0 ? {} : { timeZone };
|
|
337
|
+
const thresholds = checkedThresholds(options.thresholds);
|
|
338
|
+
const turn = checkedTurnRules(options.turn);
|
|
339
|
+
const store = limitStateStore(storage);
|
|
340
|
+
const legacy = options.windows === void 0;
|
|
341
|
+
if (!legacy && options.budget) throw new Error("\u041B\u0438\u043C\u0438\u0442\u044B \u0437\u0430\u0434\u0430\u044E\u0442\u0441\u044F \u043E\u0434\u043D\u0438\u043C \u0441\u043F\u043E\u0441\u043E\u0431\u043E\u043C: windows \u0438\u043B\u0438 budget \u0441 period, \u043D\u0435 \u043E\u0431\u043E\u0438\u043C\u0438");
|
|
342
|
+
if (legacy && !options.budget) throw new Error("\u041D\u0443\u0436\u043D\u044B \u043E\u043A\u043D\u0430 \u043B\u0438\u043C\u0438\u0442\u043E\u0432 (windows) \u0438\u043B\u0438 \u0431\u044E\u0434\u0436\u0435\u0442 (budget)");
|
|
343
|
+
const allWindows = legacy ? legacyWindows(options.period, timeZone) : normalizeWindows(options.windows, timeZone);
|
|
344
|
+
const windows = allWindows.filter((window) => window.measure === "usd");
|
|
345
|
+
const messageWindows = allWindows.filter((window) => window.measure === "messages");
|
|
346
|
+
const needsState = allWindows.find((window) => window.kind === "rolling" || window.measure === "messages");
|
|
347
|
+
if (!store && needsState) {
|
|
348
|
+
const why = needsState.measure === "messages" ? "\u0441\u0447\u0438\u0442\u0430\u0435\u0442 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F" : "\u0441\u043A\u043E\u043B\u044C\u0437\u044F\u0449\u0435\u0435";
|
|
349
|
+
throw new Error(`\u041E\u043A\u043D\u043E \xAB${needsState.id}\xBB ${why}: \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0443 \u043D\u0443\u0436\u043D\u044B \u043C\u0435\u0442\u043E\u0434\u044B getSpendingLimitState \u0438 saveSpendingLimitState`);
|
|
350
|
+
}
|
|
351
|
+
if (!store && options.onThreshold) throw new Error("onThreshold: \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0443 \u043D\u0443\u0436\u043D\u044B \u043C\u0435\u0442\u043E\u0434\u044B getSpendingLimitState \u0438 saveSpendingLimitState");
|
|
352
|
+
const unpriced = findUnpricedModels(options.catalog);
|
|
353
|
+
if (unpriced.length > 0) {
|
|
354
|
+
if ((options.unpricedModels ?? "reject") === "reject") {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`\u041D\u0435\u0442 \u0446\u0435\u043D\u044B \u0443 \u043C\u043E\u0434\u0435\u043B\u0435\u0439: ${unpriced.join(", ")}. \u0417\u0430\u0434\u0430\u0439\u0442\u0435 pricing \u0432 \u043F\u0440\u043E\u0444\u0438\u043B\u0435 \u0438\u043B\u0438 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u043B\u0438\u043C\u0438\u0442\u044B \u0441 unpricedModels: "ignore" (\u0440\u0430\u0441\u0445\u043E\u0434 \u0442\u0430\u043A\u0438\u0445 \u043C\u043E\u0434\u0435\u043B\u0435\u0439 \u043D\u0435 \u0441\u0447\u0438\u0442\u0430\u0435\u0442\u0441\u044F).`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
logError(`\u041D\u0435\u0442 \u0446\u0435\u043D\u044B \u0443 \u043C\u043E\u0434\u0435\u043B\u0435\u0439: ${unpriced.join(", ")}. \u0418\u0445 \u0445\u043E\u0434\u044B \u0437\u0430\u043F\u0438\u0441\u044B\u0432\u0430\u044E\u0442\u0441\u044F \u0441\u043E \u0441\u0442\u043E\u0438\u043C\u043E\u0441\u0442\u044C\u044E 0 \u0438 \u0432 \u043B\u0438\u043C\u0438\u0442\u044B \u043D\u0435 \u0432\u0445\u043E\u0434\u044F\u0442`);
|
|
360
|
+
}
|
|
361
|
+
function requireStore() {
|
|
362
|
+
if (!store) throw new Error("\u041B\u0438\u0447\u043D\u044B\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438 \u0438 \u0441\u0431\u0440\u043E\u0441 \u043E\u043A\u043E\u043D: \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0443 \u043D\u0443\u0436\u043D\u044B \u043C\u0435\u0442\u043E\u0434\u044B getSpendingLimitState \u0438 saveSpendingLimitState");
|
|
363
|
+
return store;
|
|
364
|
+
}
|
|
365
|
+
async function budgetFor(subject) {
|
|
366
|
+
try {
|
|
367
|
+
const budget = await options.budget({ userId: subject.userId, ...subject.chatId !== void 0 && { chatId: subject.chatId } });
|
|
368
|
+
for (const scope of ["user", "chat"]) {
|
|
369
|
+
const amount = budget[scope];
|
|
370
|
+
if (amount !== void 0 && !(typeof amount === "number" && Number.isFinite(amount) && amount >= 0)) {
|
|
371
|
+
throw new Error(`\u0411\u044E\u0434\u0436\u0435\u0442 \xAB${scope}\xBB \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u043D\u0435\u043E\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043B\u044C\u043D\u044B\u043C \u0447\u0438\u0441\u043B\u043E\u043C, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(amount)}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return budget;
|
|
375
|
+
} catch (failure) {
|
|
376
|
+
logError(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u043E\u043B\u0443\u0447\u0438\u0442\u044C \u0431\u044E\u0434\u0436\u0435\u0442 \u0440\u0430\u0441\u0445\u043E\u0434\u043E\u0432 \u0447\u0435\u043B\u043E\u0432\u0435\u043A\u0430 ${subject.userId}: ${failure instanceof Error ? failure.message : "\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430"}`);
|
|
377
|
+
throw failure;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function personOf(userId) {
|
|
381
|
+
return (store && await store.read(`person:${userId}`)) ?? {};
|
|
382
|
+
}
|
|
383
|
+
async function isExempt(subject) {
|
|
384
|
+
if (!options.exemptUsers) return false;
|
|
385
|
+
try {
|
|
386
|
+
return await options.exemptUsers({ userId: subject.userId, ...subject.chatId !== void 0 && { chatId: subject.chatId } }) === true;
|
|
387
|
+
} catch (failure) {
|
|
388
|
+
logError(`exemptUsers \u0443\u043F\u0430\u043B (\u0447\u0435\u043B\u043E\u0432\u0435\u043A ${subject.userId}): ${failure instanceof Error ? failure.message : "\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430"}`);
|
|
389
|
+
throw failure;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function limitsFor(subject, person) {
|
|
393
|
+
if (await isExempt(subject)) return /* @__PURE__ */ new Map();
|
|
394
|
+
if (legacy) {
|
|
395
|
+
const budget = await budgetFor(subject);
|
|
396
|
+
return /* @__PURE__ */ new Map([["user", budget.user], ["chat", budget.chat]]);
|
|
397
|
+
}
|
|
398
|
+
const entries = await Promise.all(
|
|
399
|
+
allWindows.map(async (window) => {
|
|
400
|
+
const own = window.scope === "service" ? void 0 : person.limits?.[window.id];
|
|
401
|
+
if (own !== void 0) return [window.id, own === null ? void 0 : own];
|
|
402
|
+
return [window.id, await window.limit(subject)];
|
|
403
|
+
})
|
|
404
|
+
);
|
|
405
|
+
return new Map(entries);
|
|
406
|
+
}
|
|
407
|
+
async function sumSince(filter, since) {
|
|
408
|
+
const sums = await storage.sumUsage({ ...filter, ...since !== null && { since } });
|
|
409
|
+
return sums.reduce((total, sum) => total + sum.costUsd, 0);
|
|
410
|
+
}
|
|
411
|
+
async function readAll(subject, person, read) {
|
|
412
|
+
const limits = read.limits ?? await limitsFor(subject, person);
|
|
413
|
+
const moment = now();
|
|
414
|
+
const readings = await Promise.all(
|
|
415
|
+
windows.map((window) => {
|
|
416
|
+
const limitUsd = limits.get(window.id);
|
|
417
|
+
if (limitUsd === void 0 && !read.withoutLimit?.(window)) return null;
|
|
418
|
+
const pendingUsd = read.pending && (window.scope === "chat" ? read.pending.chat : read.pending.user);
|
|
419
|
+
return readWindow({ window, subject, limitUsd, now: moment, store, sumSince, warnAt, open: read.open, ...pendingUsd !== void 0 && { pendingUsd } });
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
return readings.filter((reading) => reading !== null);
|
|
423
|
+
}
|
|
424
|
+
async function saveTransitions(readings) {
|
|
425
|
+
if (!store) return;
|
|
426
|
+
await Promise.all(
|
|
427
|
+
readings.filter((reading) => reading.nextState !== null).map(
|
|
428
|
+
(reading) => store.update(reading.stateKey, (current) => {
|
|
429
|
+
const currentStart2 = current === null ? void 0 : current.start;
|
|
430
|
+
return currentStart2 === reading.savedStart ? reading.nextState : null;
|
|
431
|
+
})
|
|
432
|
+
)
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
function refusal(reading, reason) {
|
|
436
|
+
const { window } = reading;
|
|
437
|
+
return new SpendingLimitReachedError({
|
|
438
|
+
scope: window.scope,
|
|
439
|
+
period: window.period,
|
|
440
|
+
resetsAt: reading.resetsAt,
|
|
441
|
+
...zone,
|
|
442
|
+
...!legacy && { reason, windowId: window.id, ...window.hours !== void 0 && { hours: window.hours } }
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async function notifyThresholds(reading, subject) {
|
|
446
|
+
if (!store || !options.onThreshold || reading.waiting || reading.limitUsd === null || reading.share === null) return;
|
|
447
|
+
const share = reading.share;
|
|
448
|
+
let crossed = [];
|
|
449
|
+
await store.update(reading.stateKey, (current) => {
|
|
450
|
+
const base = current !== null && current.start === reading.start ? current : { start: reading.start, carriedUsd: reading.carriedUsd, notified: [] };
|
|
451
|
+
crossed = thresholds.filter((threshold) => share >= threshold && !base.notified.includes(threshold));
|
|
452
|
+
return crossed.length === 0 ? null : { ...base, notified: [...base.notified, ...crossed] };
|
|
453
|
+
});
|
|
454
|
+
const { window } = reading;
|
|
455
|
+
for (const threshold of crossed) {
|
|
456
|
+
const event = {
|
|
457
|
+
windowId: window.id,
|
|
458
|
+
scope: window.scope,
|
|
459
|
+
period: window.period,
|
|
460
|
+
...window.hours !== void 0 && { hours: window.hours },
|
|
461
|
+
threshold,
|
|
462
|
+
share,
|
|
463
|
+
spentUsd: reading.spentUsd,
|
|
464
|
+
limitUsd: reading.limitUsd,
|
|
465
|
+
windowStart: reading.start,
|
|
466
|
+
resetsAt: reading.resetsAt,
|
|
467
|
+
userId: subject.userId,
|
|
468
|
+
...subject.chatId !== void 0 && { chatId: subject.chatId }
|
|
469
|
+
};
|
|
470
|
+
const report = (failure) => logError(`onThreshold \u0443\u043F\u0430\u043B (\u043E\u043A\u043D\u043E ${window.id}, \u043F\u043E\u0440\u043E\u0433 ${threshold}): ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
471
|
+
try {
|
|
472
|
+
void Promise.resolve(options.onThreshold(event)).catch(report);
|
|
473
|
+
} catch (failure) {
|
|
474
|
+
report(failure);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function notifyMessageThreshold(crossing, subject) {
|
|
479
|
+
const { window, threshold, used, limit } = crossing;
|
|
480
|
+
const event = {
|
|
481
|
+
windowId: window.id,
|
|
482
|
+
scope: window.scope,
|
|
483
|
+
period: window.period,
|
|
484
|
+
...window.hours !== void 0 && { hours: window.hours },
|
|
485
|
+
threshold,
|
|
486
|
+
share: limit === 0 ? 1 : used / limit,
|
|
487
|
+
used,
|
|
488
|
+
limit,
|
|
489
|
+
remaining: Math.max(0, limit - used),
|
|
490
|
+
windowStart: crossing.start,
|
|
491
|
+
resetsAt: crossing.resetsAt,
|
|
492
|
+
userId: subject.userId,
|
|
493
|
+
...subject.chatId !== void 0 && { chatId: subject.chatId }
|
|
494
|
+
};
|
|
495
|
+
const report = (failure) => logError(`onMessageThreshold \u0443\u043F\u0430\u043B (\u043E\u043A\u043D\u043E ${window.id}, \u043F\u043E\u0440\u043E\u0433 ${threshold}): ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
496
|
+
try {
|
|
497
|
+
void Promise.resolve(options.onMessageThreshold(event)).catch(report);
|
|
498
|
+
} catch (failure) {
|
|
499
|
+
report(failure);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function shortOfReserve(readings) {
|
|
503
|
+
const reserve = turn?.minRemainingUsd;
|
|
504
|
+
if (reserve === void 0 || readings.some((reading) => reading.state === "exhausted")) return null;
|
|
505
|
+
const short = readings.filter((reading) => remainingOf(reading) < reserve - 1e-9);
|
|
506
|
+
if (short.length === 0) return null;
|
|
507
|
+
return short.reduce((least, reading) => remainingOf(reading) < remainingOf(least) ? reading : least);
|
|
508
|
+
}
|
|
509
|
+
async function readMessages(subject, limits) {
|
|
510
|
+
if (messageWindows.length === 0) return [];
|
|
511
|
+
const moment = now();
|
|
512
|
+
const readings = await Promise.all(
|
|
513
|
+
messageWindows.map((window) => {
|
|
514
|
+
const limit = limits.get(window.id);
|
|
515
|
+
return limit === void 0 ? null : readMessageWindow({ window, subject, limit, now: moment, store, warnAt });
|
|
516
|
+
})
|
|
517
|
+
);
|
|
518
|
+
return readings.filter((reading) => reading !== null);
|
|
519
|
+
}
|
|
520
|
+
function findWindow(windowId) {
|
|
521
|
+
const window = allWindows.find((candidate) => candidate.id === windowId);
|
|
522
|
+
if (!window) throw new Error(`\u041D\u0435\u0442 \u043E\u043A\u043D\u0430 \u043B\u0438\u043C\u0438\u0442\u0430 \xAB${windowId}\xBB`);
|
|
523
|
+
return window;
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
...zone,
|
|
527
|
+
async assertWithinLimit({ userId, chatId, pendingUsd }) {
|
|
528
|
+
const subject = { userId, chatId };
|
|
529
|
+
const person = await personOf(userId);
|
|
530
|
+
if (person.blocked) {
|
|
531
|
+
throw new SpendingLimitReachedError({ scope: "user", period: "none", resetsAt: null, ...zone, reason: "blocked" });
|
|
532
|
+
}
|
|
533
|
+
const readings = await readAll(subject, person, { open: pendingUsd === void 0, ...pendingUsd && { pending: pendingUsd } });
|
|
534
|
+
const exhausted = readings.filter((reading) => reading.state === "exhausted");
|
|
535
|
+
const short = pendingUsd === void 0 && exhausted.length === 0 ? shortOfReserve(readings) : null;
|
|
536
|
+
if (exhausted.length > 0 || short) {
|
|
537
|
+
await saveTransitions(readings.filter((reading) => (reading.nextState?.carriedUsd ?? 0) > 0));
|
|
538
|
+
}
|
|
539
|
+
if (exhausted.length > 0) {
|
|
540
|
+
throw refusal(exhausted.reduce((latest2, reading) => resetsLater(reading, latest2) ? reading : latest2), "limit");
|
|
541
|
+
}
|
|
542
|
+
if (short) throw refusal(short, "reserve");
|
|
543
|
+
await saveTransitions(readings);
|
|
544
|
+
},
|
|
545
|
+
async status({ userId, chatId }) {
|
|
546
|
+
const subject = { userId, chatId };
|
|
547
|
+
const person = await personOf(userId);
|
|
548
|
+
const limits = await limitsFor(subject, person);
|
|
549
|
+
const [readings, messageReadings] = await Promise.all([readAll(subject, person, { open: false, limits }), readMessages(subject, limits)]);
|
|
550
|
+
const marked = messageWindows.length > 0;
|
|
551
|
+
const windowStatuses = legacy ? void 0 : [...readings.map((reading) => toWindowStatus(reading, marked)), ...messageReadings.map(toMessageWindowStatus)];
|
|
552
|
+
const listed = windowStatuses ? { windows: windowStatuses } : {};
|
|
553
|
+
if (person.blocked) return { state: "exhausted", reason: "blocked", blocked: true, ...zone, ...listed };
|
|
554
|
+
const judged = [...readings, ...messageReadings];
|
|
555
|
+
if (judged.length === 0) return { state: "ok", ...listed };
|
|
556
|
+
const short = shortOfReserve(readings);
|
|
557
|
+
const worst = short ?? judged.reduce((current, reading) => isWorse(reading, current) ? reading : current);
|
|
558
|
+
const state = short ? "exhausted" : worst.state;
|
|
559
|
+
const { window } = worst;
|
|
560
|
+
return {
|
|
561
|
+
state,
|
|
562
|
+
...!legacy && state === "exhausted" && { reason: short ? "reserve" : "limit" },
|
|
563
|
+
scope: window.scope,
|
|
564
|
+
period: window.period,
|
|
565
|
+
share: worst.share,
|
|
566
|
+
resetsAt: worst.resetsAt,
|
|
567
|
+
...zone,
|
|
568
|
+
...!legacy && { windowId: window.id, ...window.hours !== void 0 && { hours: window.hours } },
|
|
569
|
+
..."used" in worst && { unit: "messages", remaining: Math.max(0, worst.limit - worst.used) },
|
|
570
|
+
...listed
|
|
571
|
+
};
|
|
572
|
+
},
|
|
573
|
+
async chatDeleted({ userId, chatId }) {
|
|
574
|
+
const keys = allWindows.filter((window) => window.scope === "chat").map((window) => windowStateKey(window, { userId, chatId }));
|
|
575
|
+
await storage.deleteSpendingLimitStates?.(keys);
|
|
576
|
+
},
|
|
577
|
+
async takeMessage({ userId, chatId }) {
|
|
578
|
+
if (messageWindows.length === 0) return void 0;
|
|
579
|
+
const subject = { userId, chatId };
|
|
580
|
+
const limits = await limitsFor(subject, await personOf(userId));
|
|
581
|
+
const limited = messageWindows.flatMap((window) => {
|
|
582
|
+
const limit = limits.get(window.id);
|
|
583
|
+
return limit === void 0 ? [] : [{ window, limit }];
|
|
584
|
+
});
|
|
585
|
+
if (limited.length === 0) return void 0;
|
|
586
|
+
const { taken, refused, crossed } = await takeMessageSlots({
|
|
587
|
+
windows: limited,
|
|
588
|
+
subject,
|
|
589
|
+
now: now(),
|
|
590
|
+
store,
|
|
591
|
+
thresholds: options.onMessageThreshold ? thresholds : []
|
|
592
|
+
});
|
|
593
|
+
if (refused.length > 0) {
|
|
594
|
+
const latest2 = refused.reduce((current, candidate) => resetsLater(candidate, current) ? candidate : current);
|
|
595
|
+
const { window } = latest2;
|
|
596
|
+
throw new SpendingLimitReachedError({
|
|
597
|
+
scope: window.scope,
|
|
598
|
+
period: window.period,
|
|
599
|
+
resetsAt: latest2.resetsAt,
|
|
600
|
+
...zone,
|
|
601
|
+
reason: "limit",
|
|
602
|
+
windowId: window.id,
|
|
603
|
+
...window.hours !== void 0 && { hours: window.hours },
|
|
604
|
+
unit: "messages"
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
for (const crossing of crossed) notifyMessageThreshold(crossing, subject);
|
|
608
|
+
return taken.length > 0 ? () => giveBackMessageSlots(store, taken) : void 0;
|
|
609
|
+
},
|
|
610
|
+
async turnRules({ userId, chatId }) {
|
|
611
|
+
if (!turn) return void 0;
|
|
612
|
+
let maxCostUsd = turn.maxCostUsd;
|
|
613
|
+
if (turn.overrun === "stop") {
|
|
614
|
+
const readings = await readAll({ userId, chatId }, await personOf(userId), { open: false });
|
|
615
|
+
for (const reading of readings) {
|
|
616
|
+
if (reading.limitUsd === null) continue;
|
|
617
|
+
maxCostUsd = Math.min(maxCostUsd ?? Infinity, remainingOf(reading));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const rules = {
|
|
621
|
+
...maxCostUsd !== void 0 && Number.isFinite(maxCostUsd) && { maxCostUsd },
|
|
622
|
+
...turn.maxToolCalls !== void 0 && { maxToolCalls: turn.maxToolCalls }
|
|
623
|
+
};
|
|
624
|
+
return Object.keys(rules).length > 0 ? rules : void 0;
|
|
625
|
+
},
|
|
626
|
+
async afterSpending({ userId, chatId }) {
|
|
627
|
+
const subject = { userId, chatId };
|
|
628
|
+
const readings = await readAll(subject, await personOf(userId), { open: false });
|
|
629
|
+
await saveTransitions(readings);
|
|
630
|
+
await Promise.all(readings.map((reading) => notifyThresholds(reading, subject)));
|
|
631
|
+
},
|
|
632
|
+
async getSpending({ userId, chatId }) {
|
|
633
|
+
const subject = { userId, ...chatId !== void 0 && { chatId } };
|
|
634
|
+
const person = await personOf(userId);
|
|
635
|
+
const limits = await limitsFor(subject, person);
|
|
636
|
+
const readings = await readAll(subject, person, { open: false, limits, withoutLimit: (window) => legacy && (window.scope === "user" || chatId !== void 0) });
|
|
637
|
+
const firstOf = (scope) => readings.find((reading) => reading.window.scope === scope);
|
|
638
|
+
const user = firstOf("user");
|
|
639
|
+
const chat = chatId === void 0 ? void 0 : firstOf("chat");
|
|
640
|
+
const report = {
|
|
641
|
+
user: user ? toScopeSpending(user) : await unlimitedUser(userId),
|
|
642
|
+
...chat && { chat: toScopeSpending(chat) },
|
|
643
|
+
...zone
|
|
644
|
+
};
|
|
645
|
+
if (legacy) return report;
|
|
646
|
+
const messages = messageWindows.length > 0 && { messageWindows: (await readMessages(subject, limits)).map(toMessageWindowSpending) };
|
|
647
|
+
return { ...report, windows: readings.filter((reading) => reading.limitUsd !== null).map(toWindowSpending), ...messages, blocked: person.blocked === true };
|
|
648
|
+
},
|
|
649
|
+
async getUserSettings(userId) {
|
|
650
|
+
const person = store ? await personOf(userId) : {};
|
|
651
|
+
return { blocked: person.blocked === true, limits: { ...person.limits } };
|
|
652
|
+
},
|
|
653
|
+
async setUserSettings(userId, patch) {
|
|
654
|
+
const state = requireStore();
|
|
655
|
+
if (legacy && patch.limits && Object.keys(patch.limits).length > 0) {
|
|
656
|
+
throw new Error("\u0421\u0432\u043E\u0438 \u043B\u0438\u043C\u0438\u0442\u044B \u0447\u0435\u043B\u043E\u0432\u0435\u043A\u0430 \u0437\u0430\u0434\u0430\u044E\u0442\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F \u043E\u043A\u043E\u043D (windows); \u0441 budget \u043B\u0438\u043C\u0438\u0442 \u0447\u0435\u043B\u043E\u0432\u0435\u043A\u0430 \u2014 \u0432 \u0441\u0430\u043C\u043E\u0439 \u0444\u0443\u043D\u043A\u0446\u0438\u0438 budget");
|
|
657
|
+
}
|
|
658
|
+
for (const [windowId, amount] of Object.entries(patch.limits ?? {})) {
|
|
659
|
+
const window = findWindow(windowId);
|
|
660
|
+
if (window.scope === "service") throw new Error(`\u041E\u043A\u043D\u043E \xAB${windowId}\xBB \u2014 \u043E\u0431\u0449\u0435\u0435 \u0434\u043B\u044F \u0441\u0435\u0440\u0432\u0438\u0441\u0430: \u0441\u0432\u043E\u0435\u0433\u043E \u043B\u0438\u043C\u0438\u0442\u0430 \u0443 \u0447\u0435\u043B\u043E\u0432\u0435\u043A\u0430 \u0432 \u043D\u0451\u043C \u043D\u0435\u0442`);
|
|
661
|
+
if (amount !== null && amount !== "default") validLimit(amount, window.measure, `\u0421\u0432\u043E\u0439 \u043B\u0438\u043C\u0438\u0442 \u043E\u043A\u043D\u0430 \xAB${windowId}\xBB (\u0447\u0438\u0441\u043B\u043E, null \u0438\u043B\u0438 "default")`);
|
|
662
|
+
}
|
|
663
|
+
const { value } = await state.update(`person:${userId}`, (current) => {
|
|
664
|
+
const limits = { ...current?.limits };
|
|
665
|
+
for (const [windowId, amount] of Object.entries(patch.limits ?? {})) {
|
|
666
|
+
if (amount === "default") delete limits[windowId];
|
|
667
|
+
else limits[windowId] = amount;
|
|
668
|
+
}
|
|
669
|
+
return { blocked: patch.blocked ?? current?.blocked ?? false, limits };
|
|
670
|
+
});
|
|
671
|
+
return { blocked: value?.blocked === true, limits: { ...value?.limits } };
|
|
672
|
+
},
|
|
673
|
+
async resetWindow({ windowId, userId, chatId }) {
|
|
674
|
+
const state = requireStore();
|
|
675
|
+
const window = findWindow(windowId);
|
|
676
|
+
if (window.scope === "user" && userId === void 0) throw new Error(`\u041E\u043A\u043D\u043E \xAB${windowId}\xBB \u2014 \u043E\u043A\u043D\u043E \u0447\u0435\u043B\u043E\u0432\u0435\u043A\u0430: \u043D\u0443\u0436\u0435\u043D userId`);
|
|
677
|
+
if (window.scope === "chat" && chatId === void 0) throw new Error(`\u041E\u043A\u043D\u043E \xAB${windowId}\xBB \u2014 \u043E\u043A\u043D\u043E \u0447\u0430\u0442\u0430: \u043D\u0443\u0436\u0435\u043D chatId`);
|
|
678
|
+
const subject = { userId: userId ?? "", ...chatId !== void 0 && { chatId } };
|
|
679
|
+
const key = windowStateKey(window, subject);
|
|
680
|
+
const resetAt = now().toISOString();
|
|
681
|
+
await state.update(key, () => {
|
|
682
|
+
if (window.kind === "rolling") return { start: null, carriedUsd: 0, notified: [] };
|
|
683
|
+
const reading = currentStart(window, now());
|
|
684
|
+
return { start: reading, carriedUsd: 0, resetAt, notified: [] };
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
async function unlimitedUser(userId) {
|
|
689
|
+
const spentUsd = await sumSince({ ownerId: userId }, null);
|
|
690
|
+
return { limitUsd: null, spentUsd, remainingUsd: null, share: null, state: "ok", period: "none", periodStart: null, resetsAt: null };
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function currentStart(window, moment) {
|
|
694
|
+
if (window.kind !== "calendar") return null;
|
|
695
|
+
return readWindowStart(window, moment);
|
|
696
|
+
}
|
|
697
|
+
function readWindowStart(window, moment) {
|
|
698
|
+
return calendarBounds(window.unit, moment, window.timeZone, window.weekStartsOn).start;
|
|
699
|
+
}
|
|
700
|
+
function legacyWindows(period, timeZone) {
|
|
701
|
+
const periods = typeof period === "object" ? { user: period.user ?? DEFAULT_PERIOD, chat: period.chat ?? DEFAULT_PERIOD } : { user: period ?? DEFAULT_PERIOD, chat: period ?? DEFAULT_PERIOD };
|
|
702
|
+
return ["user", "chat"].map((scope) => {
|
|
703
|
+
const scopePeriod = periods[scope];
|
|
704
|
+
const base = { id: scope, scope, carryOverrun: false, measure: "usd", weekStartsOn: 1, timeZone: timeZone ?? "UTC", limit: async () => void 0 };
|
|
705
|
+
if (scopePeriod === "none") return { ...base, kind: "total", period: "none" };
|
|
706
|
+
if (scopePeriod !== "day" && scopePeriod !== "month" && scopePeriod !== "week") {
|
|
707
|
+
throw new Error(`period: ${String(scopePeriod)} \u2014 \u0434\u043B\u044F budget \u0442\u043E\u043B\u044C\u043A\u043E month, week, day \u0438\u043B\u0438 none; \u0441\u043A\u043E\u043B\u044C\u0437\u044F\u0449\u0438\u0435 \u043E\u043A\u043D\u0430 \u2014 \u0447\u0435\u0440\u0435\u0437 windows`);
|
|
708
|
+
}
|
|
709
|
+
return { ...base, kind: "calendar", unit: scopePeriod, period: scopePeriod };
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
function checkedTimeZone(timeZone) {
|
|
713
|
+
if (timeZone === void 0) return void 0;
|
|
714
|
+
if (resolveTimeZone2(timeZone) === void 0) {
|
|
715
|
+
throw new Error(`timeZone: \xAB${String(timeZone)}\xBB \u2014 \u043D\u0435 \u0447\u0430\u0441\u043E\u0432\u043E\u0439 \u043F\u043E\u044F\u0441 IANA; \u043D\u0443\u0436\u0435\u043D, \u043D\u0430\u043F\u0440\u0438\u043C\u0435\u0440, \xABEurope/Moscow\xBB`);
|
|
716
|
+
}
|
|
717
|
+
return timeZone;
|
|
718
|
+
}
|
|
719
|
+
function checkedThresholds(thresholds) {
|
|
720
|
+
const list = [...thresholds ?? DEFAULT_THRESHOLDS];
|
|
721
|
+
for (const threshold of list) {
|
|
722
|
+
if (!(Number.isFinite(threshold) && threshold > 0 && threshold <= 1)) {
|
|
723
|
+
throw new Error(`thresholds: \u0434\u043E\u043B\u0438 \u043B\u0438\u043C\u0438\u0442\u0430 \u0431\u043E\u043B\u044C\u0448\u0435 0 \u0438 \u043D\u0435 \u0431\u043E\u043B\u044C\u0448\u0435 1, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(threshold)}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return [...new Set(list)].sort((a, b) => a - b);
|
|
727
|
+
}
|
|
728
|
+
function checkedTurnRules(turn) {
|
|
729
|
+
if (!turn) return void 0;
|
|
730
|
+
for (const field of ["minRemainingUsd", "maxCostUsd"]) {
|
|
731
|
+
const amount = turn[field];
|
|
732
|
+
if (amount !== void 0 && !(Number.isFinite(amount) && amount >= 0)) throw new Error(`turn.${field}: \u043D\u0435\u043E\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u0447\u0438\u0441\u043B\u043E \u0434\u043E\u043B\u043B\u0430\u0440\u043E\u0432, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(amount)}`);
|
|
733
|
+
}
|
|
734
|
+
if (turn.maxToolCalls !== void 0 && !(Number.isInteger(turn.maxToolCalls) && turn.maxToolCalls >= 0)) {
|
|
735
|
+
throw new Error(`turn.maxToolCalls: \u0446\u0435\u043B\u043E\u0435 \u0447\u0438\u0441\u043B\u043E \u043E\u0442 0, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(turn.maxToolCalls)}`);
|
|
736
|
+
}
|
|
737
|
+
if (turn.overrun !== void 0 && turn.overrun !== "finish" && turn.overrun !== "stop") {
|
|
738
|
+
throw new Error(`turn.overrun: finish \u0438\u043B\u0438 stop, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${String(turn.overrun)}`);
|
|
739
|
+
}
|
|
740
|
+
return turn;
|
|
741
|
+
}
|
|
742
|
+
function findUnpricedModels(catalog) {
|
|
743
|
+
const missing = [];
|
|
744
|
+
for (const model of catalog.models) {
|
|
745
|
+
for (const providerId of Object.keys(model.providerModelIds)) {
|
|
746
|
+
if (!model.pricing?.[providerId]) missing.push(`${model.id} \xD7 ${providerId}`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return missing;
|
|
750
|
+
}
|
|
751
|
+
function remainingOf(reading) {
|
|
752
|
+
return reading.limitUsd === null ? Infinity : reading.limitUsd - reading.spentUsd;
|
|
753
|
+
}
|
|
754
|
+
function resetsLater(candidate, current) {
|
|
755
|
+
if (candidate.resetsAt === null) return current.resetsAt !== null;
|
|
756
|
+
if (current.resetsAt === null) return false;
|
|
757
|
+
return candidate.resetsAt > current.resetsAt;
|
|
758
|
+
}
|
|
759
|
+
function isWorse(candidate, current) {
|
|
760
|
+
const bySeverity = STATE_SEVERITY[candidate.state] - STATE_SEVERITY[current.state];
|
|
761
|
+
if (bySeverity !== 0) return bySeverity > 0;
|
|
762
|
+
return (candidate.share ?? 0) > (current.share ?? 0);
|
|
763
|
+
}
|
|
764
|
+
function toWindowStatus(reading, marked) {
|
|
765
|
+
const { window } = reading;
|
|
766
|
+
return {
|
|
767
|
+
id: window.id,
|
|
768
|
+
scope: window.scope,
|
|
769
|
+
period: window.period,
|
|
770
|
+
...window.hours !== void 0 && { hours: window.hours },
|
|
771
|
+
state: reading.state,
|
|
772
|
+
share: reading.share ?? 0,
|
|
773
|
+
resetsAt: reading.resetsAt,
|
|
774
|
+
...marked && { unit: "usd" }
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
function toMessageWindowStatus(reading) {
|
|
778
|
+
const { window } = reading;
|
|
779
|
+
return {
|
|
780
|
+
id: window.id,
|
|
781
|
+
scope: window.scope,
|
|
782
|
+
period: window.period,
|
|
783
|
+
...window.hours !== void 0 && { hours: window.hours },
|
|
784
|
+
state: reading.state,
|
|
785
|
+
share: reading.share,
|
|
786
|
+
resetsAt: reading.resetsAt,
|
|
787
|
+
unit: "messages",
|
|
788
|
+
limit: reading.limit,
|
|
789
|
+
used: reading.used,
|
|
790
|
+
remaining: Math.max(0, reading.limit - reading.used)
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function toMessageWindowSpending(reading) {
|
|
794
|
+
const { window } = reading;
|
|
795
|
+
return {
|
|
796
|
+
id: window.id,
|
|
797
|
+
scope: window.scope,
|
|
798
|
+
period: window.period,
|
|
799
|
+
...window.hours !== void 0 && { hours: window.hours },
|
|
800
|
+
limit: reading.limit,
|
|
801
|
+
used: reading.used,
|
|
802
|
+
remaining: Math.max(0, reading.limit - reading.used),
|
|
803
|
+
share: reading.share,
|
|
804
|
+
state: reading.state,
|
|
805
|
+
start: reading.start,
|
|
806
|
+
resetsAt: reading.resetsAt
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
function toWindowSpending(reading) {
|
|
810
|
+
const { window } = reading;
|
|
811
|
+
return {
|
|
812
|
+
id: window.id,
|
|
813
|
+
scope: window.scope,
|
|
814
|
+
period: window.period,
|
|
815
|
+
...window.hours !== void 0 && { hours: window.hours },
|
|
816
|
+
limitUsd: reading.limitUsd,
|
|
817
|
+
spentUsd: reading.spentUsd,
|
|
818
|
+
remainingUsd: Math.max(0, reading.limitUsd - reading.spentUsd),
|
|
819
|
+
share: reading.share,
|
|
820
|
+
state: reading.state,
|
|
821
|
+
start: reading.start,
|
|
822
|
+
resetsAt: reading.resetsAt,
|
|
823
|
+
carriedUsd: reading.carriedUsd
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
function toScopeSpending(reading) {
|
|
827
|
+
return {
|
|
828
|
+
limitUsd: reading.limitUsd,
|
|
829
|
+
spentUsd: reading.spentUsd,
|
|
830
|
+
remainingUsd: reading.limitUsd === null ? null : Math.max(0, reading.limitUsd - reading.spentUsd),
|
|
831
|
+
share: reading.share,
|
|
832
|
+
state: reading.state,
|
|
833
|
+
period: reading.window.period,
|
|
834
|
+
periodStart: reading.start,
|
|
835
|
+
resetsAt: reading.resetsAt
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
export {
|
|
839
|
+
createSpendingLimits
|
|
840
|
+
};
|
|
841
|
+
//# sourceMappingURL=index.js.map
|