Limenote runs AI on the person's own API key, so the least we can do is show what each request cost. Settings › AI › Usage lists every request, newest first, with its tokens and its price at the catalog rate.
Building that log for four providers taught us something we hadn't expected. The usage objects look alike and mean different things. The obvious way to add them up counts some tokens twice on one provider and misses some on another, and nothing fails loudly when you get it wrong.
What each provider's fields include
These are the fields we read from each API's final usage block, and what is already inside them:
| Provider | Total input is… | Total output is… |
|---|---|---|
| Anthropic (Messages) | input_tokens + cache_creation_input_tokens + cache_read_input_tokens. Input excludes both cache fields. |
output_tokens. Thinking is already in it. |
| OpenAI (Chat Completions) | prompt_tokens. prompt_tokens_details.cached_tokens is already in it. |
completion_tokens. completion_tokens_details.reasoning_tokens is already in it. |
| Gemini | promptTokenCount. cachedContentTokenCount is already in it. |
candidatesTokenCount + thoughtsTokenCount, or totalTokenCount − promptTokenCount. |
So there are two traps, and they point in opposite directions:
- Adding cached tokens to input is right for Anthropic and double-counts for OpenAI and Gemini.
- Taking the output field as the whole output is right for Anthropic and OpenAI and undercounts Gemini whenever it thinks.
One more: on streaming OpenAI Chat Completions, usage is only sent if you ask for it with stream_options: { include_usage: true }. Without that, the counts you think you're reading are simply absent.
APIs that copy OpenAI's response format are compatible in shape, not always in meaning. For any new provider, run one request with reasoning switched on and check the arithmetic: does total_tokens equal prompt + completion, or prompt + completion + reasoning? The answer tells you whether reasoning sits inside the completion count or beside it.
One shape that doesn't double-count
We normalize every response into one shape where the totals are always complete and the splits are only ever parts of those totals:
/**
* Cached tokens are already inside `inputTokens`, and reasoning tokens
* are already inside `outputTokens`. The splits are kept so a person can
* see them, not to be added.
*/
type TokenUsage = {
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
reasoningTokens: number;
};
function fromAnthropic(u): TokenUsage {
const cached =
(u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
return {
inputTokens: (u.input_tokens ?? 0) + cached,
outputTokens: u.output_tokens ?? 0,
cachedInputTokens: cached,
reasoningTokens: 0,
};
}
function fromOpenAI(u): TokenUsage {
return {
inputTokens: u.prompt_tokens ?? 0,
outputTokens: u.completion_tokens ?? 0,
cachedInputTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
reasoningTokens: u.completion_tokens_details?.reasoning_tokens ?? 0,
};
}
function fromGemini(m): TokenUsage {
const input = m.promptTokenCount ?? 0;
const thoughts = m.thoughtsTokenCount ?? 0;
// The total already counts thinking once. Without it, add thinking ourselves.
const total = m.totalTokenCount;
const output = typeof total === 'number' && total >= input
? total - input
: (m.candidatesTokenCount ?? 0) + thoughts;
return {
inputTokens: input,
outputTokens: output,
cachedInputTokens: m.cachedContentTokenCount ?? 0,
reasoningTokens: thoughts,
};
}
The rule the shape enforces: cost only ever looks at inputTokens and outputTokens. The splits exist for display and for debugging, and the type's comment says so. Once your schema has a field called cachedTokens sitting next to inputTokens, someone will eventually add them together.
We also clamp every count to a sane range, because a provider sending null, a negative number or something absurd shouldn't produce a negative bill.
When there are no counts at all
Sometimes there are no usage numbers: the stream was cut, the provider didn't send them, or the request was stopped early. You can fall back to an estimate, but say that it's one. We use the usual rough rule of about four characters per token, over the system prompt, every message and the streamed output.
That estimate has a blind spot worth stating plainly: it doesn't include image tokens. Most of our requests carry an image of the region someone selected, so an estimated row can be well below what the provider counted. We mark estimated rows, and flag the ones that had an image.
Log every request, not just the good ones
A cost log that only shows successful answers will disagree with what people remember doing. So every request gets a row, with a status:
- complete: the answer finished normally.
- partial: the person pressed stop, or the stream broke after some output arrived. Those tokens were still generated.
- error: it failed before anything came back.
A retry is a new row. A failed request with no counted tokens is listed with no price and left out of the totals, and the log says so. Here's a real one, from a test account with a deliberately fake key:

Price it honestly
Cost per row is input × input rate + output × output rate, at the catalog list price. Three things keep that number honest:
- Store the rates on the row. We save the per-million input and output rates used for each request. When a provider changes its prices, old rows keep the price they actually had instead of being quietly recalculated.
- Say what the number is not. We price cached input at the full input rate, so our figure is an upper bound. The provider's invoice can be lower when a cache discount applies. The note above the log says exactly that, along with the estimate caveat.
- Don't delete history with the content. Deleting a document doesn't remove its usage rows. The money was spent either way.
The short version
- Learn what each provider's fields contain, not just what they're called.
- Normalize to totals plus splits, and price only the totals.
- Ask for usage on streams.
- Label estimates, and remember they miss images.
- Log failures, stops and retries.
- Store the rate with each row.
None of it is hard. It's just easy to get wrong without noticing.

