Skip to content

Commit 280f9d5

Browse files
BillChiricoBill Chirico
andauthored
feat: per-guild AI spend tracking and daily budget cap (#200)
* feat: add per-guild AI spend tracking and daily budget cap (#167) - Add guildSpend.js utility with getGuildSpend() and checkGuildBudget() - Gate triage evaluations when guild exceeds dailyBudgetUsd - Log warning at 80% budget utilization (Phase 3) - Post alert to moderation log channel on budget exceeded (Phase 3) - Add triage.dailyBudgetUsd: 10 default to config.json * test: add tests for guild spend tracking and triage budget gate (#167) - 13 unit tests for getGuildSpend() and checkGuildBudget() - 8 integration tests for triage budget gate behavior: - allows evaluation when budget is ok or no budget configured - logs warning at 80% utilization, continues evaluation - blocks evaluation when budget is exceeded - sends alert to moderation log channel on exceeded budget - non-fatal when budget check errors (evaluation continues) * style: fix biome import ordering and formatting in budget gate files * fix(triage): throttle budget-exceeded alert to prevent spam Track lastBudgetAlertAt per guild in a module-level Map. Only post the moderation log channel alert if no alert has been sent within BUDGET_ALERT_COOLDOWN_MS (1 hour). Prevents repeated 'AI spend cap reached' messages flooding the mod log on every evaluation attempt while a guild is over budget. Resolves review thread PRRT_kwDORICdSM5xdS6p * fix(triage): ensure clearEvaluatedMessages runs on budget gate return Move the budget gate block inside the outer try block so the finally { clearEvaluatedMessages(channelId, snapshotIds) } clause always executes, even when we return early because the guild has exhausted its daily budget. Previously the early return escaped the try/finally entirely, leaving snapshot message IDs in the channel buffer where they could be reprocessed on the next evaluation tick. Resolves review thread PRRT_kwDORICdSM5xdS6v --------- Co-authored-by: Bill Chirico <[email protected]>
1 parent fe33ffd commit 280f9d5

5 files changed

Lines changed: 677 additions & 1 deletion

File tree

config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"debugFooter": true,
4343
"debugFooterLevel": "verbose",
4444
"moderationLogChannel": "1473219285651292201",
45-
"statusReactions": true
45+
"statusReactions": true,
46+
"dailyBudgetUsd": 10
4647
},
4748
"welcome": {
4849
"enabled": true,

src/modules/triage.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import { debug, info, error as logError, warn } from '../logger.js';
1919
import { loadPrompt, promptPath } from '../prompts/index.js';
20+
import { fetchChannelCached } from '../utils/discordCache.js';
21+
import { checkGuildBudget } from '../utils/guildSpend.js';
2022
import { safeSend } from '../utils/safeSend.js';
2123
import { CLIProcess, CLIProcessError } from './cli-process.js';
2224
import { buildMemoryContext, extractAndStoreMemories } from './memory.js';
@@ -66,6 +68,14 @@ let classifierProcess = null;
6668
/** @type {CLIProcess|null} */
6769
let responderProcess = null;
6870

71+
// ── Budget alert throttle ────────────────────────────────────────────────────
72+
// Track the last time a budget-exceeded alert was posted per guild so we don't
73+
// spam the moderation log channel on every evaluation attempt.
74+
/** @type {Map<string, number>} guildId → timestamp of last alert (ms) */
75+
const budgetAlertSentAt = new Map();
76+
/** Minimum gap between budget-exceeded alerts for the same guild (1 hour). */
77+
const BUDGET_ALERT_COOLDOWN_MS = 60 * 60 * 1_000;
78+
6979
// ── Two-step CLI evaluation ──────────────────────────────────────────────────
7080

7181
/**
@@ -333,6 +343,63 @@ async function evaluateAndRespond(channelId, snapshot, evalConfig, evalClient) {
333343
const snapshotIds = new Set(snapshot.map((m) => m.messageId));
334344

335345
try {
346+
// ── Guild daily budget gate ─────────────────────────────────────────────
347+
// Skip evaluation if the guild has exhausted its daily AI spend cap.
348+
// This prevents runaway costs from high-volume guilds.
349+
// NOTE: kept inside the try block so the finally { clearEvaluatedMessages }
350+
// always runs — even when we return early due to budget exhaustion.
351+
const dailyBudgetUsd = evalConfig.triage?.dailyBudgetUsd;
352+
if (dailyBudgetUsd != null && dailyBudgetUsd > 0) {
353+
try {
354+
const ch = await fetchChannelCached(evalClient, channelId);
355+
const guildId = ch?.guildId;
356+
if (guildId) {
357+
const budget = await checkGuildBudget(guildId, dailyBudgetUsd);
358+
if (budget.status === 'exceeded') {
359+
warn('Guild daily AI budget exceeded — skipping triage evaluation', {
360+
guildId,
361+
channelId,
362+
spend: budget.spend,
363+
budget: budget.budget,
364+
});
365+
// Post a throttled alert to the moderation log channel — at most once per
366+
// BUDGET_ALERT_COOLDOWN_MS — to avoid spamming on every evaluation attempt.
367+
const logChannelId = evalConfig.triage?.moderationLogChannel;
368+
if (logChannelId) {
369+
const now = Date.now();
370+
const lastAlert = budgetAlertSentAt.get(guildId) ?? 0;
371+
if (now - lastAlert >= BUDGET_ALERT_COOLDOWN_MS) {
372+
budgetAlertSentAt.set(guildId, now);
373+
fetchChannelCached(evalClient, logChannelId)
374+
.then((logCh) => {
375+
if (logCh) {
376+
return safeSend(
377+
logCh,
378+
`⚠️ **AI spend cap reached** for guild \`${guildId}\` — daily budget of $${budget.budget.toFixed(2)} exceeded (spent $${budget.spend.toFixed(4)}). Triage evaluations are paused until the window resets.`,
379+
);
380+
}
381+
})
382+
.catch(() => {});
383+
}
384+
}
385+
return;
386+
}
387+
if (budget.status === 'warning') {
388+
warn('Guild approaching daily AI budget limit', {
389+
guildId,
390+
channelId,
391+
spend: budget.spend,
392+
budget: budget.budget,
393+
pct: Math.round(budget.pct * 100),
394+
});
395+
}
396+
}
397+
} catch (budgetErr) {
398+
// Non-fatal: if budget check errors, allow evaluation to continue
399+
debug('Guild budget check failed (non-fatal)', { channelId, error: budgetErr?.message });
400+
}
401+
}
402+
336403
// Step 1: Classify
337404
const classResult = await runClassification(channelId, snapshot, evalConfig, evalClient);
338405
if (!classResult) return;

src/utils/guildSpend.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Per-guild AI spend tracking and enforcement utilities.
3+
*
4+
* Queries the `ai_usage` table to compute cumulative spend for a guild within a
5+
* configurable time window (default: 24 hours). Used by the triage module to
6+
* gate evaluations when a guild exceeds its daily AI budget.
7+
*/
8+
9+
import { getPool } from '../db.js';
10+
import { warn } from '../logger.js';
11+
12+
/**
13+
* Query cumulative AI spend for a guild within a rolling time window.
14+
*
15+
* Returns 0 when the database pool is unavailable, when guildId is falsy, or
16+
* when no rows match.
17+
*
18+
* @param {string} guildId - Discord guild ID.
19+
* @param {number} [windowMs=86400000] - Rolling window in milliseconds (default: 24 h).
20+
* @returns {Promise<number>} Total spend in USD for the window period.
21+
*/
22+
export async function getGuildSpend(guildId, windowMs = 24 * 60 * 60 * 1000) {
23+
if (!guildId) return 0;
24+
25+
let pool;
26+
try {
27+
pool = getPool();
28+
} catch {
29+
return 0;
30+
}
31+
32+
try {
33+
const since = new Date(Date.now() - windowMs);
34+
const { rows } = await pool.query(
35+
'SELECT COALESCE(SUM(cost_usd), 0) AS total FROM ai_usage WHERE guild_id = $1 AND created_at >= $2',
36+
[guildId, since],
37+
);
38+
return parseFloat(rows[0]?.total ?? 0);
39+
} catch (err) {
40+
warn('getGuildSpend query failed', { guildId, error: err?.message });
41+
return 0;
42+
}
43+
}
44+
45+
/**
46+
* Check whether a guild has exceeded (or is approaching) its configured daily AI budget.
47+
*
48+
* Returns a structured result so callers can decide how to act:
49+
* - 'exceeded' — spend >= dailyBudgetUsd (block evaluation)
50+
* - 'warning' — spend >= 80% of dailyBudgetUsd (log a warning, continue)
51+
* - 'ok' — under 80% (no action needed)
52+
*
53+
* @param {string} guildId - Discord guild ID.
54+
* @param {number} dailyBudgetUsd - Configured budget cap in USD.
55+
* @param {number} [windowMs=86400000] - Rolling window in milliseconds (default: 24 h).
56+
* @returns {Promise<{status: 'ok'|'warning'|'exceeded', spend: number, budget: number, pct: number}>}
57+
*/
58+
export async function checkGuildBudget(guildId, dailyBudgetUsd, windowMs = 24 * 60 * 60 * 1000) {
59+
const spend = await getGuildSpend(guildId, windowMs);
60+
const pct = dailyBudgetUsd > 0 ? spend / dailyBudgetUsd : 0;
61+
62+
let status;
63+
if (pct >= 1) {
64+
status = 'exceeded';
65+
} else if (pct >= 0.8) {
66+
status = 'warning';
67+
} else {
68+
status = 'ok';
69+
}
70+
71+
return { status, spend, budget: dailyBudgetUsd, pct };
72+
}

0 commit comments

Comments
 (0)