In short: every OpenAI response tells you how many tokens it used. To get cost per user, read that usage, multiply it by the model's price, and add it to a running total keyed by your user ID. Then set it next to what that user pays you.
The OpenAI dashboard shows spend by project, key and model, but not by your users. If you charge a flat subscription, that gap is where margin quietly disappears: one user can cost pennies and another can cost more than they pay.
Every non-streaming Chat Completions response includes a usage block. You only need three numbers from it, and you never need the message text.
const res = await openai.chat.completions.create({ model, messages });
const u = res.usage;
// u.prompt_tokens input tokens (includes cached ones)
// u.prompt_tokens_details.cached_tokens the part of the input served from cache
// u.completion_tokens output tokens (includes reasoning tokens)Streaming: a stream returns no usage unless you ask for it. Pass stream_options: { include_usage: true } and read the final chunk, otherwise your streamed calls cost money and show up as zero.
Providers publish prices per million tokens, with separate rates for input, cached input and output. The formula:
cost = (prompt_tokens - cached) × input_rate
+ cached × cached_rate
+ completion_tokens × output_rate (rates are USD per token = price ÷ 1,000,000)// Example rates only. Look up the current numbers on the provider's pricing page.
const PRICE_PER_MTOK = { "gpt-5-mini": { input: 0.25, cached: 0.025, output: 2.0 } };
function costUsd(model: string, u: Usage) {
const p = PRICE_PER_MTOK[model];
if (!p) return null; // unknown model: flag it, don't count it as $0
const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
return ((u.prompt_tokens - cached) * p.input + cached * p.cached + u.completion_tokens * p.output) / 1e6;
}Three mistakes that make the numbers wrong:
prompt_tokens already includes the cached ones, so subtract them before applying the input rate. Anthropic does the opposite: its input_tokens excludes cache reads and writes.Write one row per call (with a unique event ID so a retry can't count twice) and roll it up per user per day. The daily rollup is what you query for dashboards; the raw rows are only for debugging, so you can delete them after a while.
events(id uuid primary key, user_id, ts, model, input_tokens, cached_tokens, output_tokens, cost_usd, feature)
daily_user_cost(user_id, day, cost_usd, requests) -- updated in the same transaction as the insertDo this off the request path (a queue, or waitUntil on serverless), so a database hiccup never slows or fails a user's AI call.
Cost alone isn't the answer. A user costing $8 a month is fine on a $30 plan and a loss on a $7.49 plan. You need revenue per user, net of store fees and tax, and the same user ID on both sides. If you sell through the App Store or Google Play, RevenueCat webhooks give you that.
Then margin is simply net revenue − AI cost over the same window. Sort users by it, and the ones losing you money are at the top.
Steps 2 to 4 are the same for everyone, so we built them. One line after each call sends the model and token counts (never your prompts or answers). Prices, cached tokens, de-duplication, per-user rollups, revenue and alerts are handled.
import { createMeter } from "fiscallm";
const meter = createMeter({ apiKey: process.env.FISCALLM_KEY, waitUntil });
const res = await openai.chat.completions.create({ model, messages });
meter.trackOpenAI(res, { userId, feature: "chat" });from fiscallm import Meter
meter = Meter(api_key=os.environ["FISCALLM_KEY"])
res = client.chat.completions.create(model=model, messages=messages)
meter.track_openai(res, user_id=user_id, feature="chat")It's free and open source. See the demo dashboard (no sign-up), read how it works, or start free.
Not by your own users. It groups by project, API key and model. Some teams create one key per user, which doesn't scale; tracking usage in your own code does.
Not if you send events in the background and never await them on the request path.
No. Cost depends only on the model and the token counts. Never send message content.