Why your provider's bill cannot tell you
OpenAI's Costs API groups spend by project, API key, line item and source. Anthropic's Usage and Cost API groups by workspace, API key and model. Neither documents a breakdown by your customers, because the provider never learns who they are. The only place that knows "this request was for customer 4512" is your application, at the moment it makes the call. So that is where cost per customer has to be recorded.
You could approximate it with one API key or project per customer. That works for a handful of large customers and breaks at a few hundred: keys to provision, limits per project, and no view across them.
Record every call, priced on the way out
Each response carries the token counts you need. The fields that change the price:
| Provider | Field | How it is priced |
|---|---|---|
| OpenAI | usage.prompt_tokens | Input, including the cached part |
| OpenAI | usage.prompt_tokens_details.cached_tokens | The cached part, at the cached-input rate |
| OpenAI | usage.completion_tokens | Output, including completion_tokens_details.reasoning_tokens, which bill as output |
| Anthropic | usage.input_tokens | Input not read from or written to the cache |
| Anthropic | usage.cache_read_input_tokens, cache_creation_input_tokens | Cache reads and writes, each at its own rate |
| Anthropic | usage.output_tokens | Output |
Price with the rate your contract carries, and record the model you asked for: the response often names a dated snapshot, which your rate table will not match.
import OpenAI from "openai";
const client = new OpenAI();
// One row per request: the llm_usage table from schema.sql. Kept in memory
// here; in production, insert it into your own database.
interface UsageRow {
ts: string;
customerId: string;
feature: string;
model: string;
inputTokens: number; // including cached
cachedTokens: number;
outputTokens: number; // including reasoning
costUsd: number | null; // null = no rate for this model: unpriced, never $0
}
const ledger: UsageRow[] = [];
// USD per 1M tokens, from your contract. A model missing here is unpriced.
const RATES: Record<string, { input: number; cachedInput: number; output: number }> = {
"gpt-5.4-mini": { input: 0.75, cachedInput: 0.075, output: 4.5 },
};
export function costUsd(model: string, usage: OpenAI.CompletionUsage): number | null {
const rate = RATES[model];
if (!rate) return null;
const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;
// Reasoning tokens are already inside completion_tokens and bill as output.
return ((usage.prompt_tokens - cached) * rate.input + cached * rate.cachedInput + usage.completion_tokens * rate.output) / 1_000_000;
}
export async function complete(customerId: string, feature: string, model: string, prompt: string) {
const res = await client.chat.completions.create({
model,
messages: [{ role: "user", content: prompt }],
// A stable, opaque id per customer: OpenAI uses it for abuse detection.
safety_identifier: customerId,
});
const usage = res.usage!;
ledger.push({
ts: new Date().toISOString(),
customerId,
feature,
model, // the model you asked for: the response names a dated snapshot
inputTokens: usage.prompt_tokens,
cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
outputTokens: usage.completion_tokens,
costUsd: costUsd(model, usage),
});
return res.choices[0]?.message.content ?? "";
}
await complete("cus_a", "summaries", "gpt-5.4-mini", "Summarize this ticket.");
await complete("cus_a", "summaries", "gpt-5.4-mini", "Summarize this other ticket.");
await complete("cus_b", "drafts", "ft:gpt-5.4-mini:acme:support:a1b2", "Draft a reply.");
// The same report as report.sql: cost per customer this month, unpriced counted.
const monthStart = new Date().toISOString().slice(0, 8) + "01";
const byCustomer = new Map<string, { requests: number; cost: number | null; unpriced: number }>();
for (const row of ledger.filter((r) => r.ts >= monthStart)) {
const agg = byCustomer.get(row.customerId) ?? { requests: 0, cost: null, unpriced: 0 };
agg.requests += 1;
if (row.costUsd === null) agg.unpriced += 1;
else agg.cost = (agg.cost ?? 0) + row.costUsd;
byCustomer.set(row.customerId, agg);
}
for (const [customerId, r] of [...byCustomer].sort((a, b) => (b[1].cost ?? -1) - (a[1].cost ?? -1))) {
const cost = r.cost === null ? "unpriced" : `$${r.cost.toFixed(6)}`;
console.log(`${customerId} requests=${r.requests} cost=${cost} unpriced=${r.unpriced}`);
}import datetime
import sqlite3 # use your own database in production
from pathlib import Path
from openai import OpenAI
HERE = Path(__file__).parent # schema.sql and report.sql sit beside this file
client = OpenAI()
db = sqlite3.connect(":memory:")
db.executescript((HERE / "schema.sql").read_text())
# USD per 1M tokens, from your contract. A model missing here is unpriced.
RATES = {
"gpt-5.4-mini": {"input": 0.75, "cached_input": 0.075, "output": 4.5},
}
def cost_usd(model: str, usage) -> float | None:
rate = RATES.get(model)
if rate is None:
return None
details = usage.prompt_tokens_details
cached = (details.cached_tokens or 0) if details else 0
# Reasoning tokens are already inside completion_tokens and bill as output.
return (
(usage.prompt_tokens - cached) * rate["input"]
+ cached * rate["cached_input"]
+ usage.completion_tokens * rate["output"]
) / 1_000_000
def complete(customer_id: str, feature: str, model: str, prompt: str) -> str:
res = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
# A stable, opaque id per customer: OpenAI uses it for abuse detection.
safety_identifier=customer_id,
)
usage = res.usage
details = usage.prompt_tokens_details
db.execute(
"INSERT INTO llm_usage VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
datetime.datetime.now(datetime.timezone.utc).isoformat(),
customer_id,
feature,
model, # the model you asked for: the response names a dated snapshot
usage.prompt_tokens,
(details.cached_tokens or 0) if details else 0,
usage.completion_tokens,
cost_usd(model, usage),
),
)
return res.choices[0].message.content or ""
if __name__ == "__main__":
complete("cus_a", "summaries", "gpt-5.4-mini", "Summarize this ticket.")
complete("cus_a", "summaries", "gpt-5.4-mini", "Summarize this other ticket.")
complete("cus_b", "drafts", "ft:gpt-5.4-mini:acme:support:a1b2", "Draft a reply.")
rows = db.execute((HERE / "report.sql").read_text()).fetchall()
for customer_id, requests, cost, unpriced in rows:
shown = "unpriced" if cost is None else f"${cost:.6f}"
print(f"{customer_id} requests={requests} cost={shown} unpriced={unpriced}")Send the same id to OpenAI as safety_identifier, which replaces the deprecated user field: OpenAI uses it to detect abuse by one of your users without blocking the rest. Use an opaque id, never an email address.
The table and the report
One row per request, and one query. In production this is a table in your own database, written asynchronously so it never slows the request.
CREATE TABLE llm_usage (
ts TEXT NOT NULL,
customer_id TEXT NOT NULL,
feature TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL, -- including cached
cached_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL, -- including reasoning
cost_usd REAL -- NULL = no rate for this model: unpriced, never $0
);Then cost per customer, with unpriced requests counted beside the total rather than silently left out of it:
Cost per customer this month
-- Cost per customer this month. Unpriced requests are counted, not hidden.
SELECT customer_id,
COUNT(*) AS requests,
SUM(cost_usd) AS cost_usd,
SUM(cost_usd IS NULL) AS unpriced
FROM llm_usage
WHERE ts >= strftime('%Y-%m-01', 'now')
GROUP BY customer_id
ORDER BY cost_usd DESC;Three ways the number goes wrong
- Unpriced shown as $0. A model with no rate in your table (a fine-tune, a new snapshot, a provider you just added) is not free. Store
NULL, count it, and alert when the unpriced share rises. - List price instead of your rate. If you have negotiated or committed-use rates, a ledger priced at list overstates cost, and your margin per customer looks worse than it is. Keep a rate per model and per provider account.
- Retries and fallbacks dropped. A request that failed over to a second provider can bill on both. Record every attempt that returned usage, against the same customer, and the total matches the invoice better.
Check the total against the provider's own cost report once a month. A gap of a few percent is rounding and timing; more means a code path that does not record.
What to build, and when
- A key or project per customer
- if you have a handful of large customers and want the provider's own reports to split them.
- A usage table in your database
- if you have many customers and one integration point where every model call passes.
- A gateway that records it
- if calls come from several services or providers and you want the number without instrumenting each.
- Per-customer limits too
- if one customer's usage can hurt your margin; see the spending-limit guide.
Questions
- Can OpenAI tell me the cost per customer?
- Not directly. OpenAI's cost reports group by project, API key and line item; they do not know your customers. Record each call's usage against your own customer id, or give each customer its own project.
- How do I price cached tokens?
- Split input into cached and uncached. OpenAI reports the cached part in prompt_tokens_details.cached_tokens; Anthropic reports cache reads and writes separately. Each has its own rate on the provider's pricing page.
- Should I pass my customer id to OpenAI?
- Pass an opaque id as safety_identifier, which replaces the deprecated user field. It helps OpenAI single out abuse by one of your users. Never send an email address or name.