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 allow before spending, with a message the user understands.
  • Identify users from the token; use the IP only for anonymous traffic.
  • Alert on total spend rate.