Add per-user daily token quota with persistent usage tracking

This commit is contained in:
2026-08-21 12:25:57 -07:00
parent 2c3dc66878
commit f1200b7d79
6 changed files with 199 additions and 45 deletions

View File

@@ -8,12 +8,26 @@ MOCK_AI=false
# Required for the AI draft button, unless MOCK_AI=true. # Required for the AI draft button, unless MOCK_AI=true.
ANTHROPIC_API_KEY=your-anthropic-api-key-here ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Required for company deployment. Leave both blank for solo local use with # Named users. Comma-separated user:password pairs. Each name doubles as the
# no login prompt. Set both to require a login for anyone reaching this tool. # identity used for the per-user token quota below.
# Example: APP_USERS=alice:pass1,bob:pass2,carol:pass3
# Leave blank for solo local use with no login prompt (usage is then tracked
# under the identity "local").
APP_USERS=
# Legacy single shared login. Only used if APP_USERS is blank. Everyone who
# logs in with this pair shares one identity and one token quota.
APP_USERNAME= APP_USERNAME=
APP_PASSWORD= APP_PASSWORD=
# Optional. Caps AI draft calls per source IP, to limit cost from the shared # Optional. Caps AI draft calls per source IP, to catch a runaway script
# ANTHROPIC_API_KEY. Defaults: 20 requests per 5 minutes. # fast. Defaults: 20 requests per 5 minutes.
RATE_LIMIT_MAX=20 RATE_LIMIT_MAX=20
RATE_LIMIT_WINDOW_MS=300000 RATE_LIMIT_WINDOW_MS=300000
# Caps combined input+output tokens per named user, per UTC calendar day.
# Default: 50000 tokens/day (roughly 100-200 AI drafts with this tool's
# prompt size). Usage is written to TOKEN_USAGE_FILE and persists across
# restarts if that file's folder is a mounted volume.
TOKEN_LIMIT_PER_USER=50000
TOKEN_USAGE_FILE=./data/token-usage.json

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules/
Thumbs.db Thumbs.db
*.log *.log
.env .env
/data/

View File

@@ -26,14 +26,15 @@ If either command fails, install Docker before you continue.
3. Set a test value for each line: 3. Set a test value for each line:
``` ```
ANTHROPIC_API_KEY=<a personal or trial key, for testing only> ANTHROPIC_API_KEY=<a personal or trial key, for testing only>
APP_USERNAME=tester APP_USERS=tester:test-password-123,tester2:test-password-456
APP_PASSWORD=test-password-123
RATE_LIMIT_MAX=5 RATE_LIMIT_MAX=5
RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_WINDOW_MS=60000
TOKEN_LIMIT_PER_USER=2000
TOKEN_USAGE_FILE=./data/token-usage.json
``` ```
4. Save the file. 4. Save the file.
Use a personal or trial API key here, not the company-billed key. Keep the company key for the real deployment. Set `APP_USERNAME` and `APP_PASSWORD` so you can confirm the login gate works. `RATE_LIMIT_MAX=5` with a 60 second window makes the rate limit easy to trigger on purpose, for testing. Use a personal or trial API key here, not the company-billed key. Keep the company key for the real deployment. `APP_USERS` sets up two logins so you can confirm each person gets a separate quota. `RATE_LIMIT_MAX=5` with a 60 second window makes the rate limit easy to trigger on purpose. `TOKEN_LIMIT_PER_USER=2000` is deliberately low, so you can hit the daily quota in a couple of clicks instead of a couple hundred.
`.env` is not tracked by Git. Docker Compose reads it automatically because `docker-compose.yml` lists it under `env_file`. `.env` is not tracked by Git. Docker Compose reads it automatically because `docker-compose.yml` lists it under `env_file`.
@@ -59,14 +60,14 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
``` ```
docker compose logs docker compose logs
``` ```
4. Confirm the log reports whether it found `ANTHROPIC_API_KEY` and whether a login is required. 4. Confirm the log reports whether it found `ANTHROPIC_API_KEY`, how many named users are configured, and the per-user token quota.
## Step 5: Test in a browser ## Step 5: Test in a browser
1. Open a browser. 1. Open a browser.
2. Go to `http://localhost:5173`. 2. Go to `http://localhost:5173`.
3. Confirm the browser asks for a username and password. 3. Confirm the browser asks for a username and password.
4. Enter the `APP_USERNAME` and `APP_PASSWORD` values from your `.env` file. 4. Enter one of the `APP_USERS` pairs from your `.env` file, for example `tester` / `test-password-123`.
5. Confirm the toolkit's landing page loads, with links to both tools. 5. Confirm the toolkit's landing page loads, with links to both tools.
6. Open each tool link and confirm it loads. 6. Open each tool link and confirm it loads.
@@ -91,13 +92,26 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
2. Confirm the next click returns a rate-limit message instead of a normal draft or a silent failure. 2. Confirm the next click returns a rate-limit message instead of a normal draft or a silent failure.
3. Wait for the time window to pass, then confirm the button works again. 3. Wait for the time window to pass, then confirm the button works again.
## Step 9: Stop the container ## Step 9: Test the per-user token quota
1. Log in as `tester` and click the AI draft button once or twice, until the response reports a quota error instead of a draft. With `TOKEN_LIMIT_PER_USER=2000`, this takes one or two clicks.
2. Confirm the error names `tester`, the tokens used, the limit, and a countdown to the reset.
3. Open a new private or incognito browser window and log in as `tester2` instead.
4. Confirm `tester2` can still click the AI draft button. Each named user has a separate quota.
5. Run this command to view the usage file directly:
```
cat data/token-usage.json
```
6. Confirm it lists a separate entry for each user who made a call, with today's date and a token count.
7. Run `docker compose restart`, then confirm `tester` is still blocked. The quota survives a restart because `data` is a mounted volume.
## Step 10: Stop the container
1. Run this command: 1. Run this command:
``` ```
docker compose down docker compose down
``` ```
2. This stops and removes the container. It does not delete your `.env` file or the project folder. 2. This stops and removes the container. It does not delete your `.env` file, your `data` folder, or the project folder.
## Rebuild after a code change ## Rebuild after a code change
@@ -114,7 +128,10 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
Stop whatever else is using that port, or change the port mapping in `docker-compose.yml` from `"5173:5173"` to, for example, `"5180:5173"`. Then open `http://localhost:5180` instead. Stop whatever else is using that port, or change the port mapping in `docker-compose.yml` from `"5173:5173"` to, for example, `"5180:5173"`. Then open `http://localhost:5180` instead.
**The browser does not ask for a login.** **The browser does not ask for a login.**
Check that both `APP_USERNAME` and `APP_PASSWORD` are set in `.env`, with no typos in the variable names. Restart the container after any `.env` change: `docker compose up -d --build`. Check that `APP_USERS` is set in `.env` (or the legacy `APP_USERNAME`/`APP_PASSWORD` pair), with no typos in the variable names. Restart the container after any `.env` change: `docker compose up -d --build`.
**Everyone seems to share one quota, or a user's quota did not reset the next day.**
Check that each person has their own entry in `APP_USERS`, not one shared `APP_USERNAME`/`APP_PASSWORD`. Quota resets happen on UTC calendar days, which may be a few hours off from your local midnight.
**The AI draft button reports a missing key.** **The AI draft button reports a missing key.**
Check that `ANTHROPIC_API_KEY` is set in `.env` and is a real key. Restart the container after the change. Check that `ANTHROPIC_API_KEY` is set in `.env` and is a real key. Restart the container after the change.

View File

@@ -128,32 +128,41 @@ The `.gitignore` file excludes `node_modules` and common system files from commi
## Deploy for the whole company ## Deploy for the whole company
Use this when the tool needs to be reachable by anyone on the internal network or VPN, not just on one person's machine. This uses a company-owned Anthropic API key shared by everyone who reaches the tool, so it adds a login gate and a request cap that a solo local setup does not need. Use this when the tool needs to be reachable by anyone on the internal network or VPN, not just on one person's machine. This uses a company-owned Anthropic API key shared by everyone who reaches the tool, so it adds a per-person login, a per-person daily token quota, and a request cap that a solo local setup does not need.
1. Get an Anthropic API key billed to a company account, not a personal one. IT or finance should provision this, since it is billed like any other company vendor cost. 1. Get an Anthropic API key billed to a company account, not a personal one. IT or finance should provision this, since it is billed like any other company vendor cost.
2. Pick a shared username and password for this tool. This is a simple login gate, not a full identity system. It exists so the tool is not reachable by anyone who is merely on the same network segment, but treat the VPN and internal network as the primary control, not this login. 2. Decide who needs their own login. Each name gets its own password and its own daily token quota, tracked separately.
3. On the host or container platform, set these values as environment variables, or in a `.env` file next to `docker-compose.yml`: 3. On the host or container platform, set these values as environment variables, or in a `.env` file next to `docker-compose.yml`:
``` ```
ANTHROPIC_API_KEY=<company key> ANTHROPIC_API_KEY=<company key>
APP_USERNAME=<shared username> APP_USERS=alice:pass1,bob:pass2,carol:pass3
APP_PASSWORD=<shared password> TOKEN_LIMIT_PER_USER=50000
``` ```
4. Build and run the container: 4. Build and run the container:
``` ```
docker compose up -d --build docker compose up -d --build
``` ```
5. To run this alongside the existing Work Package Suite container instead of on its own, copy the `sde-meeting-toolkit` service block from `docker-compose.yml` into that stack's compose file, and apply the same environment variables there. 5. To run this alongside the existing Work Package Suite container instead of on its own, copy the `sde-meeting-toolkit` service block from `docker-compose.yml` into that stack's compose file, including its `volumes` entry, and apply the same environment variables there.
6. Confirm the login prompt appears when you open the tool's URL from another machine on the network. 6. Confirm the login prompt appears when you open the tool's URL from another machine on the network, and that it accepts one of the named user/password pairs.
### What the login gate does and does not do ### What the login gate does and does not do
- It requires a username and password before any page or API call on this tool succeeds. - It requires a username and password before any page or API call on this tool succeeds, and identifies which named user made each AI draft call.
- It does not encrypt traffic on its own. Run this behind the same network and VPN protections used for the Work Package Suite, and add TLS at the reverse proxy or load balancer if one is already in place for that stack. - It does not encrypt traffic on its own. Run this behind the same network and VPN protections used for the Work Package Suite, and add TLS at the reverse proxy or load balancer if one is already in place for that stack.
- It does not track who made which AI draft request. Every user shares one login and one API key. If per-person attribution matters later, that needs a real identity integration, which is a larger change than this tool currently supports. - It is a shared-credential list, not a real identity system. Anyone who has a name's password can use that name's quota. If real single-sign-on attribution matters later, that needs a larger integration than this tool currently supports.
- Old single-shared-login setups still work: set `APP_USERNAME` and `APP_PASSWORD` instead of `APP_USERS` if you want everyone to share one login and one quota, unchanged from before this feature existed.
### Per-user token quota
`TOKEN_LIMIT_PER_USER` caps combined input and output tokens per named user, per UTC calendar day. Default: 50000 tokens/day, which is roughly 100 to 200 AI drafts with this tool's prompt size. A user who hits the cap gets a clear error naming their usage and the time until reset, instead of a silent failure or an unexplained cost.
Usage is written to `TOKEN_USAGE_FILE` (default `./data/token-usage.json`) after every AI call. In Docker, `docker-compose.yml` mounts `./data` as a volume so this file survives a restart or redeploy. If you remove that volume mount, usage resets to zero every time the container restarts, which defeats the point of a daily cap.
To reset one person's quota early, stop the container, edit their entry out of the usage file (or set its `date` to any past date), and restart. To raise or lower the cap for everyone, change `TOKEN_LIMIT_PER_USER` and restart; the change applies from that point on, not retroactively.
### Rate limit ### Rate limit
`RATE_LIMIT_MAX` and `RATE_LIMIT_WINDOW_MS` cap AI draft requests per source IP address, to prevent a leaked link or a stuck script from running up cost on the shared key. Defaults: 20 requests per 5 minutes. Raise these in `.env` if real usage hits the limit; the tool returns a clear rate-limit error rather than failing silently. `RATE_LIMIT_MAX` and `RATE_LIMIT_WINDOW_MS` cap AI draft requests per source IP address, independent of the token quota. This catches a runaway script in the first few seconds, before it could burn through a whole day's token quota. Defaults: 20 requests per 5 minutes.
## Relation to the Work Package Suite ## Relation to the Work Package Suite

View File

@@ -14,3 +14,7 @@ services:
- "5173:5173" - "5173:5173"
env_file: env_file:
- .env - .env
volumes:
# Persists the per-user token quota file across restarts and rebuilds.
# Without this, everyone's daily usage silently resets on every deploy.
- ./data:/app/data

157
server.js
View File

@@ -3,6 +3,7 @@
* Serves the static tool files. * Serves the static tool files.
* Proxies AI draft requests to the Anthropic API, so the API key * Proxies AI draft requests to the Anthropic API, so the API key
* stays on the server and never appears in the browser. * stays on the server and never appears in the browser.
* Identifies each caller by login and enforces a daily token quota per person.
*/ */
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
@@ -49,17 +50,52 @@ function sendJson(res, status, obj) {
} }
/* ===== Access control ===== /* ===== Access control =====
* Set APP_USERNAME and APP_PASSWORD (in .env or the container environment) * Named users. Set APP_USERS as a comma-separated list of user:password
* to require a login for the whole app. Leave both unset for solo local use * pairs, for example: APP_USERS=alice:pass1,bob:pass2
* with no login prompt. This is a simple shared-credential gate, meant to sit * Each name is also the identity used for the daily token quota below.
* behind the company VPN and internal network, not to replace them. */ *
* Legacy single-user mode: set APP_USERNAME and APP_PASSWORD instead. That
* name becomes the one identity everyone shares (no per-person quota).
*
* Leave all of the above unset for solo local use: no login prompt, and
* usage is tracked under the identity "local".
*
* This is a simple shared-credential gate, meant to sit behind the company
* VPN and internal network, not to replace them. */
function loadUserMap() {
const map = {};
if (process.env.APP_USERS) {
process.env.APP_USERS.split(',').forEach((pair) => {
const idx = pair.indexOf(':');
if (idx === -1) return;
const name = pair.slice(0, idx).trim();
const pass = pair.slice(idx + 1).trim();
if (name && pass) map[name] = pass;
});
} else if (process.env.APP_USERNAME && process.env.APP_PASSWORD) {
map[process.env.APP_USERNAME] = process.env.APP_PASSWORD;
}
return map;
}
const USER_MAP = loadUserMap();
const AUTH_REQUIRED = Object.keys(USER_MAP).length > 0;
// Returns the identified username on success, or null on failure.
function checkBasicAuth(req) { function checkBasicAuth(req) {
const user = process.env.APP_USERNAME; if (!AUTH_REQUIRED) return 'local';
const pass = process.env.APP_PASSWORD;
if (!user || !pass) return true;
const header = req.headers['authorization'] || ''; const header = req.headers['authorization'] || '';
const expected = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); if (!header.startsWith('Basic ')) return null;
return header === expected; let decoded;
try {
decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
} catch (err) {
return null;
}
const idx = decoded.indexOf(':');
if (idx === -1) return null;
const name = decoded.slice(0, idx);
const pass = decoded.slice(idx + 1);
return USER_MAP[name] === pass ? name : null;
} }
function requireAuth(res) { function requireAuth(res) {
res.writeHead(401, { res.writeHead(401, {
@@ -70,9 +106,9 @@ function requireAuth(res) {
} }
/* ===== Rate limiting for the AI proxy ===== /* ===== Rate limiting for the AI proxy =====
* A shared company API key means one leaked link or one runaway script can * Caps AI draft calls per source IP, independent of the token quota below.
* generate real cost. This caps AI draft calls per source IP. It resets if * This catches a runaway script quickly, before it burns through a whole
* the process restarts; that is an accepted tradeoff for a small internal tool. */ * day's token quota in a few seconds. Resets if the process restarts. */
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '20', 10); const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '20', 10);
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || String(5 * 60 * 1000), 10); const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || String(5 * 60 * 1000), 10);
const rateLimitHits = new Map(); const rateLimitHits = new Map();
@@ -84,13 +120,66 @@ function isRateLimited(ip) {
return hits.length > RATE_LIMIT_MAX; return hits.length > RATE_LIMIT_MAX;
} }
/* ===== Per-user daily token quota =====
* TOKEN_LIMIT_PER_USER caps combined input+output tokens per identified user,
* per UTC calendar day. Usage is written to TOKEN_USAGE_FILE after every AI
* call, so it survives a container restart. Mount that file's folder as a
* volume in Docker or it resets on every redeploy.
*
* The check runs before the API call using the day's tally so far: if the
* user is already at or over the limit, the call is refused with no cost.
* A single call in progress when the limit is reached can still push the
* tally past the cap by that one call's tokens; the next call is blocked.
* That is an accepted tradeoff, since real token cost of a call is only
* known once its response returns. */
const TOKEN_LIMIT_PER_USER = parseInt(process.env.TOKEN_LIMIT_PER_USER || '50000', 10);
const TOKEN_USAGE_FILE = process.env.TOKEN_USAGE_FILE || path.join(ROOT, 'data', 'token-usage.json');
function todayUTC() {
return new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
}
function loadUsage() {
try {
return JSON.parse(fs.readFileSync(TOKEN_USAGE_FILE, 'utf8'));
} catch (err) {
return {};
}
}
function saveUsage(usage) {
try {
fs.mkdirSync(path.dirname(TOKEN_USAGE_FILE), { recursive: true });
fs.writeFileSync(TOKEN_USAGE_FILE, JSON.stringify(usage));
} catch (err) {
console.error('Could not write token usage file:', err.message);
}
}
function usedTokensToday(usage, user) {
const rec = usage[user];
if (!rec || rec.date !== todayUTC()) return 0;
return rec.tokens;
}
function addTokens(user, tokens) {
const usage = loadUsage();
const today = todayUTC();
const rec = usage[user] && usage[user].date === today ? usage[user] : { date: today, tokens: 0 };
rec.tokens += tokens;
usage[user] = rec;
saveUsage(usage);
return rec.tokens;
}
function secondsUntilUTCMidnight() {
const now = new Date();
const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
return Math.round((midnight - now) / 1000);
}
/* ===== Mock mode ===== /* ===== Mock mode =====
* Set MOCK_AI=true to test the whole app, including the AI draft button, * Set MOCK_AI=true to test the whole app, including the AI draft button and
* with no API key and no real API call. Returns a canned breadcrumb draft * the token quota above, with no API key and no real API call. Returns a
* shaped like a real Anthropic response, so the frontend parsing and gate * canned breadcrumb draft with a synthetic usage count, shaped like a real
* checks run exactly as they would against the live API. Use this for * Anthropic response, so the frontend parsing, gate checks, and quota
* testing the login gate, rate limit, and Docker setup without cost. */ * accounting all run exactly as they would against the live API. */
function mockClaudeResponse(res) { function mockClaudeResponse(res, user) {
const draft = { const draft = {
domain: 'MOCK: Progress Visibility', domain: 'MOCK: Progress Visibility',
driver: 'MOCK driver: schedule forecasts drift from field reality.', driver: 'MOCK driver: schedule forecasts drift from field reality.',
@@ -99,16 +188,19 @@ function mockClaudeResponse(res) {
method: 'MOCK method: monthly spot audit of 30+ sampled assets.', method: 'MOCK method: monthly spot audit of 30+ sampled assets.',
baseline: 'MOCK baseline: first audit result.' baseline: 'MOCK baseline: first audit result.'
}; };
const usage = { input_tokens: 950, output_tokens: 280 };
const body = { const body = {
content: [{ type: 'text', text: JSON.stringify(draft) }] content: [{ type: 'text', text: JSON.stringify(draft) }],
usage
}; };
addTokens(user, usage.input_tokens + usage.output_tokens);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
} }
function proxyToClaude(req, res) { function proxyToClaude(req, res, user) {
if (String(process.env.MOCK_AI).toLowerCase() === 'true') { if (String(process.env.MOCK_AI).toLowerCase() === 'true') {
mockClaudeResponse(res); mockClaudeResponse(res, user);
return; return;
} }
if (!process.env.ANTHROPIC_API_KEY) { if (!process.env.ANTHROPIC_API_KEY) {
@@ -133,6 +225,14 @@ function proxyToClaude(req, res) {
const text = await upstream.text(); const text = await upstream.text();
res.writeHead(upstream.status, { 'Content-Type': 'application/json; charset=utf-8' }); res.writeHead(upstream.status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(text); res.end(text);
try {
const parsed = JSON.parse(text);
if (parsed.usage) {
addTokens(user, (parsed.usage.input_tokens || 0) + (parsed.usage.output_tokens || 0));
}
} catch (err) {
// Response was not JSON, or had no usage field. Nothing to record.
}
} catch (err) { } catch (err) {
sendJson(res, 502, { error: 'Could not reach the Anthropic API: ' + err.message }); sendJson(res, 502, { error: 'Could not reach the Anthropic API: ' + err.message });
} }
@@ -172,7 +272,8 @@ function serveStatic(req, res) {
} }
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if (!checkBasicAuth(req)) { const user = checkBasicAuth(req);
if (!user) {
requireAuth(res); requireAuth(res);
return; return;
} }
@@ -182,7 +283,14 @@ const server = http.createServer((req, res) => {
sendJson(res, 429, { error: `Rate limit reached (${RATE_LIMIT_MAX} AI drafts per ${Math.round(RATE_LIMIT_WINDOW_MS / 60000)} min). Wait a bit and try again.` }); sendJson(res, 429, { error: `Rate limit reached (${RATE_LIMIT_MAX} AI drafts per ${Math.round(RATE_LIMIT_WINDOW_MS / 60000)} min). Wait a bit and try again.` });
return; return;
} }
proxyToClaude(req, res); const usedToday = usedTokensToday(loadUsage(), user);
if (usedToday >= TOKEN_LIMIT_PER_USER) {
sendJson(res, 429, {
error: `Daily token quota reached (${usedToday}/${TOKEN_LIMIT_PER_USER} tokens for "${user}"). Resets in ${secondsUntilUTCMidnight()}s, at UTC midnight.`
});
return;
}
proxyToClaude(req, res, user);
return; return;
} }
if (req.method === 'GET') { if (req.method === 'GET') {
@@ -200,5 +308,6 @@ server.listen(PORT, () => {
} else { } else {
console.log(process.env.ANTHROPIC_API_KEY ? 'ANTHROPIC_API_KEY loaded: AI draft button is live.' : 'No ANTHROPIC_API_KEY found: AI draft button will return an error until you add one to .env.'); console.log(process.env.ANTHROPIC_API_KEY ? 'ANTHROPIC_API_KEY loaded: AI draft button is live.' : 'No ANTHROPIC_API_KEY found: AI draft button will return an error until you add one to .env.');
} }
console.log((process.env.APP_USERNAME && process.env.APP_PASSWORD) ? 'Login required: APP_USERNAME/APP_PASSWORD are set.' : 'No login required: APP_USERNAME/APP_PASSWORD are not set.'); console.log(AUTH_REQUIRED ? `Login required: ${Object.keys(USER_MAP).length} named user(s) configured.` : 'No login required: no APP_USERS or APP_USERNAME/APP_PASSWORD are set.');
console.log(`Per-user daily token quota: ${TOKEN_LIMIT_PER_USER} tokens. Usage file: ${TOKEN_USAGE_FILE}`);
}); });