AI Usage and Billing
ULTIMATE
The AI agent reports the model tokens of each call through the usage hook. Where to store them, how to price them, whom to charge and when to stop is up to you.
What the hook receives
agent({
usage: {
set: async function(guid, usage, auth, request) {
// guid the document
// usage { input, output, cacheRead, cacheWrite }: tokens of one model call
// auth the request's auth object: auth.token is the user's token
// request { ip, prompt, files, bytes, session, req }
},
get: async function(guid, auth) {
// served at GET /api/<guid>/usage
},
},
});
| Field | Meaning |
|---|---|
input |
Input tokens that were not served from the cache |
output |
Tokens the model generated |
cacheWrite |
Input tokens written to the prompt cache (the document context, on the first call of a conversation) |
cacheRead |
Input tokens read from the prompt cache, billed at a fraction of the input price |
Keep the four counters apart: providers price them differently (output usually several times the input, cache reads a small fraction of it), so a single total cannot be turned into cost later.
One prompt, several calls. usage.set runs once per model call, not once per prompt; a request that reads the sheet, writes values and then answers reports several calls. Group them with request.session and request.prompt if needed.
Never throw. The hook runs inside the answer: catch and log storage errors, so a failed metrics write does not end the user's answer.
Who is the user
The server has no user table; decode the user from the token as your authentication hooks do:
const jwt = require('jsonwebtoken');
const getUser = function(auth) {
try {
return jwt.verify(auth.token, process.env.JWT_SECRET).sub;
} catch (e) {
return null;
}
};
Anonymous users (on a public demo, for example) have no subject; fall back to request.ip as an approximation.
Storing usage
PostgreSQL
One row per user, document and day, incremented atomically:
CREATE TABLE ai_usage (
user_id TEXT NOT NULL,
guid TEXT NOT NULL,
day DATE NOT NULL,
model TEXT NOT NULL,
input BIGINT NOT NULL DEFAULT 0,
output BIGINT NOT NULL DEFAULT 0,
cache_read BIGINT NOT NULL DEFAULT 0,
cache_write BIGINT NOT NULL DEFAULT 0,
calls INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, guid, day, model)
);
const MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-6';
agent({
usage: {
set: async function(guid, usage, auth, request) {
const user = getUser(auth) || 'ip:' + request.ip;
try {
await pool.query(`
INSERT INTO ai_usage (user_id, guid, day, model, input, output, cache_read, cache_write, calls)
VALUES ($1, $2, CURRENT_DATE, $3, $4, $5, $6, $7, 1)
ON CONFLICT (user_id, guid, day, model) DO UPDATE SET
input = ai_usage.input + EXCLUDED.input,
output = ai_usage.output + EXCLUDED.output,
cache_read = ai_usage.cache_read + EXCLUDED.cache_read,
cache_write = ai_usage.cache_write + EXCLUDED.cache_write,
calls = ai_usage.calls + 1`,
[user, guid, MODEL, usage.input, usage.output, usage.cacheRead, usage.cacheWrite]);
} catch (e) {
console.error('ai usage', e);
}
},
get: async function(guid, auth) {
const { rows } = await pool.query(
`SELECT day, SUM(input) AS input, SUM(output) AS output, SUM(cache_read) AS cache_read, SUM(cache_write) AS cache_write
FROM ai_usage WHERE user_id = $1 AND guid = $2 GROUP BY day ORDER BY day DESC LIMIT 30`,
[getUser(auth), guid]);
return rows;
},
},
});
Reports are plain SQL on this table: spend per user this month, the most expensive documents, the share of cached input, the days a budget was hit.
MongoDB
agent({
usage: {
set: async function(guid, usage, auth, request) {
const user = getUser(auth) || 'ip:' + request.ip;
const day = new Date().toISOString().slice(0, 10);
try {
await db.collection('ai_usage').updateOne(
{ user, guid, day },
{ $inc: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, calls: 1 } },
{ upsert: true });
} catch (e) {
console.error('ai usage', e);
}
},
get: async function(guid, auth) {
return await db.collection('ai_usage').find({ user: getUser(auth), guid }).sort({ day: -1 }).limit(30).toArray();
},
},
});
Index { user: 1, guid: 1, day: 1 } so the upsert stays fast.
From tokens to cost
Keep prices in configuration, per model and per million tokens:
// Prices per million tokens, from your provider's price list
const PRICES = {
'claude-haiku-4-5': { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
'claude-sonnet-4-6': { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
const cost = function(model, usage) {
const p = PRICES[model];
return (usage.input * p.input
+ usage.output * p.output
+ usage.cacheRead * p.cacheRead
+ usage.cacheWrite * p.cacheWrite) / 1000000;
};
Store tokens and compute cost when reporting: prices change, token counts do not.
Showing usage to users
GET /api/<guid>/usage returns what your usage.get returns for the caller's token, so a page can show "you used 120,000 tokens on this workbook today":
const response = await fetch(SERVER + '/api/' + guid + '/usage', {
headers: { Authorization: 'Bearer ' + token },
});
const days = await response.json();
For a total across documents, add your own route with the extension API, or serve it from your application, which already has the table.
Budgets
allow decides what may be spent next. Read the same storage in allow and refuse once a budget is reached; the user sees your message and nothing is sent to the model:
agent({
allow: async function(guid, auth, request) {
const user = getUser(auth);
if (! user) {
return 'Please sign in to use the AI assistant.';
}
const { rows } = await pool.query(
`SELECT COALESCE(SUM(input + output + cache_write + cache_read / 10), 0) AS tokens
FROM ai_usage WHERE user_id = $1 AND day >= date_trunc('month', CURRENT_DATE)`,
[user]);
const plan = await plans.of(user); // your own plans
return Number(rows[0].tokens) < plan.monthlyTokens
|| 'Your plan includes ' + plan.monthlyTokens + ' AI tokens a month. Upgrade to continue.';
},
});
Budgets per plan, per team or per document are the same query with a different WHERE. The AI agent page has the version for anonymous visitors, with a per-address limit, a site-wide budget and a rate limit in Redis.
Billing
Stripe usage-based billing
Report usage to a Stripe billing meter and Stripe invoices it with the subscription. Send one meter event per model call, or aggregate and send periodically:
const stripe = require('stripe')(process.env.STRIPE_KEY);
agent({
usage: {
set: async function(guid, usage, auth, request) {
const customer = await customers.of(getUser(auth)); // your user to Stripe customer map
if (! customer) {
return;
}
try {
await stripe.billing.meterEvents.create({
event_name: 'ai_tokens',
payload: {
stripe_customer_id: customer,
value: String(usage.input + usage.output + usage.cacheWrite),
},
});
} catch (e) {
console.error('stripe meter', e);
}
},
},
});
To bill input and output separately, create two meters, ai_input_tokens and ai_output_tokens, each attached to its own price.
Credits
For prepaid credits, allow checks the balance and usage.set deducts the cost of each call. Deduct atomically in your database (UPDATE ... SET credits = credits - $1 WHERE user_id = $2) so concurrent answers cannot overspend by more than one call.
Analytics and monitoring
Product analytics
One event per model call shows who uses the assistant, on which documents and how much:
const Mixpanel = require('mixpanel');
const mixpanel = Mixpanel.init(process.env.MIXPANEL_TOKEN);
agent({
usage: {
set: async function(guid, usage, auth, request) {
mixpanel.track('ai_call', {
distinct_id: getUser(auth) || request.ip,
guid: guid,
session: request.session,
input: usage.input,
output: usage.output,
cache_read: usage.cacheRead,
});
},
},
});
The same shape works with PostHog, Segment or Amplitude.
Monitoring and alerts
Expose spend to Prometheus as labelled counters:
const client = require('prom-client');
const tokens = new client.Counter({
name: 'jss_ai_tokens_total',
help: 'AI tokens used by the Jspreadsheet agent',
labelNames: ['type'],
});
agent({
usage: {
set: async function(guid, usage) {
tokens.inc({ type: 'input' }, usage.input);
tokens.inc({ type: 'output' }, usage.output);
tokens.inc({ type: 'cache_read' }, usage.cacheRead);
tokens.inc({ type: 'cache_write' }, usage.cacheWrite);
},
},
});
Alert on the rate of jss_ai_tokens_total to catch a runaway integration. Avoid user ids as labels, which multiply the series; keep per-user data in your database.
Several destinations
Call each destination from usage.set with its own error handling, so a slow analytics call does not hold up billing:
agent({
usage: {
set: async function(guid, usage, auth, request) {
await Promise.allSettled([
store(guid, usage, auth, request),
meter(guid, usage, auth),
track(guid, usage, auth, request),
]);
},
get: read,
},
});
Checklist
- Store input, output, cache reads and cache writes separately, per user and day.
- Compute cost from a price table when reporting, not when storing.
- Never throw from
usage.set. - Refuse in
allowbefore spending, with a message the user understands. - Identify users from the token; use the IP only for anonymous traffic.
- Alert on total spend rate.