Skip to content

Guide

How to track LLM cost per customer in a SaaS app

Record every model call with your own customer id, the model you asked for and the provider's token counts, price it at your own rates (cached input at the cached rate, reasoning tokens as output), and store "no rate" as unpriced rather than $0. Then cost per customer is a GROUP BY over that table, because neither OpenAI's nor Anthropic's cost reports know who your customers are.

By INFRO Editorial

Last updated

Last checked

On this page
  1. Why your provider's bill cannot tell you
  2. Record every call, priced on the way out
  3. The table and the report
  4. Cost per customer this month
  5. Three ways the number goes wrong
  6. What to build, and when
  7. Cost per customer through INFRO
  8. Questions
  9. Sources

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:

ProviderFieldHow it is priced
OpenAIusage.prompt_tokensInput, including the cached part
OpenAIusage.prompt_tokens_details.cached_tokensThe cached part, at the cached-input rate
OpenAIusage.completion_tokensOutput, including completion_tokens_details.reasoning_tokens, which bill as output
Anthropicusage.input_tokensInput not read from or written to the cache
Anthropicusage.cache_read_input_tokens, cache_creation_input_tokensCache reads and writes, each at its own rate
Anthropicusage.output_tokensOutput
Field names from OpenAI's API reference and Anthropic's Messages API reference.

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}`);
}
Tested: 1,000 input tokens (200 cached) and 400 output tokens cost $0.002415 at these rates; a fine-tuned model with no rate is reported as unpriced, not $0.

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;
Both SQL files are executed by the Python sample in the tests above.

Three ways the number goes wrong

  1. 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.
  2. 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.
  3. 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.

Sources

Every statement about another company on this page comes from that company's own page, opened on the date shown. Prices and defaults change; check the source before you decide.

  1. 1.developers.openai.com/api/reference/cli/resources/completions/methods/create (opens in a new tab) — checked Oct 24, 2026.
    • Chat completion usage reports cached input tokens in prompt_tokens_details.cached_tokens and reasoning tokens in completion_tokens_details.reasoning_tokens.
  2. 2.developers.openai.com/api/docs/guides/reasoning (opens in a new tab) — checked Oct 24, 2026.
    • Reasoning tokens are billed as output tokens.
  3. 3.developers.openai.com/api/reference/python/resources/chat/subresources/completions/methods/create (opens in a new tab) — checked Oct 24, 2026.
    • OpenAI's user request parameter is deprecated in favour of safety_identifier (a stable id for policy enforcement) and prompt_cache_key.
  4. 4.developers.openai.com/api/reference/go/resources/admin/subresources/organization/subresources/usage/methods/costs (opens in a new tab) — checked Oct 24, 2026.
    • OpenAI's Costs API groups spend by project, API key, line item and source; its user_id grouping is not documented as your own end customers.
  5. 5.platform.claude.com/docs/en/api/messages/create (opens in a new tab) — checked Oct 24, 2026.
    • Anthropic's usage object reports input_tokens, output_tokens, cache_creation_input_tokens and cache_read_input_tokens; total input is the sum of the three input fields.
  6. 6.platform.claude.com/docs/en/manage-claude/usage-cost-api (opens in a new tab) — checked Oct 24, 2026.
    • Anthropic's Usage and Cost Admin API groups usage by API key, workspace, model and more, and cost by workspace or description, daily; it has no per-end-customer breakdown.

The team building INFRO, a BYOK-first control plane for production AI. Articles are written from the engineering work on the gateway and cite their external sources.