tokentally
v0.1.7
Published
Token usage in, dollar totals out.
Readme
tokentally 🧮 — Count the tokens. Mind the tab.
tokentally is a TypeScript library for normalizing LLM provider token usage and estimating USD cost. Its core API works in browsers and Node.js; optional Node helpers load pricing and model limits from LiteLLM or OpenRouter.
import { estimateUsdCost, normalizeTokenUsage, pricingFromUsdPerMillion } from "tokentally";
const usage = normalizeTokenUsage({ prompt_tokens: 1_000, completion_tokens: 250 });
const pricing = pricingFromUsdPerMillion({ inputUsdPerMillion: 1.75, outputUsdPerMillion: 14 });
console.log(estimateUsdCost({ usage, pricing })?.totalUsd);
// 0.00525Install
pnpm add tokentallytokentally requires Node.js 24 or newer when used in Node.js projects. The package is ESM-only.
Quick start
Save the example above as cost.mjs, then run it:
$ node cost.mjs
0.00525normalizeTokenUsage() accepts common snake_case and camelCase provider fields. It returns
null when it cannot find a recognized token count, so unknown payloads do not silently become
zero-cost calls.
Normalize usage
The normalizer understands OpenAI-style prompt_tokens and completion_tokens, Anthropic-style
input_tokens and output_tokens, camelCase variants, cached input details, and reasoning token
details. Missing recognized counts become zero, and a missing total is inferred from the fields
that are present.
import { normalizeTokenUsage } from "tokentally";
const usage = normalizeTokenUsage({
input_tokens: 120,
output_tokens: 30,
cache_read_input_tokens: 80,
});
// { inputTokens: 120, outputTokens: 30, uncachedInputTokens: 120,
// cachedInputTokens: 80, totalTokens: 230 }Price and tally calls
Pricing is expressed as USD per token. Use pricingFromUsdPerMillion() for the rates commonly
published by providers, or pricingFromUsdPerToken() when the source already uses per-token
values. Both helpers accept optional cache-read and cache-creation rates. When a catalog does not
publish a cache rate, cache tokens fall back to the ordinary input rate rather than being treated
as free. CostBreakdown.inputUsd includes all three input categories.
| API | Purpose |
| --------------------------------------- | ---------------------------------------------------- |
| normalizeTokenUsage(raw) | Normalize common provider usage shapes |
| pricingFromUsdPerMillion(rates) | Convert published per-million rates |
| pricingFromUsdPerToken(rates) | Validate per-token rates |
| resolvePricingFromMap(map, modelId) | Resolve exact and common provider-prefixed model IDs |
| estimateUsdCost({ usage, pricing }) | Price one normalized call |
| tallyCosts({ calls, resolvePricing }) | Aggregate calls and cost by model |
tallyCosts() accepts a synchronous or asynchronous pricing resolver. Calls without usage still
count in the per-model breakdown; models without pricing retain their usage but have a null
cost and do not contribute to the total. Model maps use their own entries only; IDs such as
constructor and __proto__ are treated as ordinary model IDs.
Cache ambiguity and strict validation
When constructing usage manually, supply uncachedInputTokens whenever either
cachedInputTokens or cacheCreationInputTokens is present, including a zero cache count.
The uncached count excludes both cache reads and cache creation. OpenAI's prompt count includes
cache reads; Anthropic's input count excludes cache reads and writes. A flattened object alone
cannot tell the estimator which interpretation you intended.
For example, with input at $1, output at $2, and cache reads at $0.10 per million tokens:
| Usage interpretation | Input | Cached | Uncached | Total for 50 output tokens | | ------------------------ | ----- | ------ | -------- | -------------------------- | | OpenAI inclusive input | 1,000 | 400 | 600 | $0.00074 | | Anthropic additive input | 1,000 | 400 | 1,000 | $0.00114 |
The default still treats inputTokens as uncached when uncachedInputTokens is missing. The
ambiguous manual object { inputTokens: 1000, outputTokens: 50, cachedInputTokens: 400 }
therefore retains its $0.00114 estimate. The result now includes a single structured warning:
warnings: [{ code: "AMBIGUOUS_CACHED_INPUT", message: "..." }]. Inspect cost?.warnings from
estimateUsdCost() or result.warnings from tallyCosts(). Warnings are omitted for unambiguous
usage and deduplicated to one entry per result, even when a tally contains many ambiguous calls.
They are returned on each affected invocation, never written to the console or stderr.
Opt into rejection with requireExplicitUncachedInputTokens: true on either function:
import {
estimateUsdCost,
normalizeTokenUsage,
pricingFromUsdPerMillion,
tallyCosts,
} from "tokentally";
const pricing = pricingFromUsdPerMillion({
inputUsdPerMillion: 1,
outputUsdPerMillion: 2,
cachedInputUsdPerMillion: 0.1,
});
const usage = normalizeTokenUsage({
prompt_tokens: 1000,
completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 400 },
});
const cost = estimateUsdCost({ usage, pricing, requireExplicitUncachedInputTokens: true });
// cost.totalUsd is 0.00074; no warnings.
const result = await tallyCosts({
calls: [{ model: "example/model", usage }],
resolvePricing: () => pricing,
requireExplicitUncachedInputTokens: true,
});
// result.total.totalUsd is 0.00074; no warnings.Strict estimation throws a TypeError for ambiguous input; strict tallying rejects its promise.
The error explains how to normalize the original provider payload or pass an explicit uncached
count. Validation checks every original call before aggregation and pricing resolution, so mixing
normalized and ambiguous manual calls cannot hide the ambiguity. It also applies when pricing is
missing. Default estimation with missing usage or pricing still returns null; a default tally
can return warnings even when its total is null because no pricing was found.
Normalize the original provider payload, not an already flattened ambiguous object: lost
provider semantics cannot be recovered. For manual OpenAI usage in the table above, passing
uncachedInputTokens: 600 is sufficient; manual Anthropic usage needs uncachedInputTokens: 1000.
Ambiguous manual usage is deprecated. Strict validation is planned to become the default in a future compatibility-changing release; its major/minor version and migration timing remain undecided. This release keeps strict validation opt-in and preserves existing numeric totals.
Load catalog pricing in Node.js
Import catalog helpers from tokentally/node. The LiteLLM loader uses a seven-day disk cache at
$HOME/.tokentally/cache; set TOKENTALLY_CACHE_DIR to put it elsewhere. Disk caching is optional:
when no cache directory is configured or saving fails, a successful fetch still returns network
pricing. Failed refreshes fall back to a readable cached catalog.
import { loadLiteLlmCatalog, resolveLiteLlmPricing } from "tokentally/node";
const { catalog, source } = await loadLiteLlmCatalog({ env: process.env, fetchImpl: fetch });
const pricing = catalog ? resolveLiteLlmPricing(catalog, "openai/gpt-5.2") : null;
console.log({ source, pricing });OpenRouter requires an API key supplied by your application. Its loader rejects malformed response collections before caching, skips malformed model rows, and caches validated results for five minutes from fetch completion:
import { resolvePricingFromMap } from "tokentally";
import { fetchOpenRouterPricingMap } from "tokentally/node";
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) throw new Error("Set OPENROUTER_API_KEY");
const map = await fetchOpenRouterPricingMap({
apiKey,
fetchImpl: fetch,
});
const pricing = resolvePricingFromMap(map, "openai/gpt-5.2");Catalog prices and limits can change. tokentally estimates cost from the source you provide; it does not reconcile provider invoices.
Development
pnpm install
pnpm checkpnpm check runs formatting, linting, type checks, tests with coverage, the package build, and a
smoke test of the built public exports. CI runs the full gate on Node.js 24 and 26.
