Problem
TokenUsage reports billed quantities but never the unit they're counted in. Today:
unitsBilled?: number is a bare count — the doc explicitly says "the unit itself (megapixels, seconds, images, …) is provider-defined and not carried here." (packages/ai-event-client/src/index.ts)
durationSeconds?: number carries seconds, but only by convention/field name.
A consumer that receives unitsBilled: 5 cannot tell whether that's 5 seconds of video, 5 images, or 5 megapixels. The unit is exposed nowhere machine-readable — the OTel middleware emits tanstack.ai.usage.units_billed unitless, and the only "seconds"/"images" strings in the codebase are hardcoded UI labels and JSDoc.
This already bites the media example (examples/ts-react-media/src/components/VideoGenerator.tsx), which has to guess the unit from a proxy signal — it branches on whether cost is present to decide between rendering "N seconds of video" vs "N fal units". That's fragile and only works because the two current providers happen to differ on cost presence.
It also drives inconsistency between adapters for the same concept: video length is reported as durationSeconds by the Gemini video adapter but unitsBilled by the fal/grok video adapters.
Proposal
Add a field that names the billed unit, alongside the quantity:
interface TokenUsage<TProviderDetails = ProviderUsageDetails> {
// ...existing fields...
/**
* The unit the billed quantity is counted in. Lets consumers label/aggregate
* usage without out-of-band knowledge of the provider/activity.
*/
billingUnit: BillingUnit
}
type BillingUnit =
| 'tokens' // chat, summarize, embedding
| 'seconds' // video, audio/music, transcription, some TTS
| 'characters' // some TTS (e.g. OpenAI)
| 'images' // image generation
| 'megapixels' // image generation
| 'units' // opaque provider-defined unit (e.g. fal's "fal units")
| (string & {}) // escape hatch for provider-specific units
(Open to a better name — billingUnit, billedUnit, usageUnit. billingUnit pairs cleanly with the existing unitsBilled quantity. The (string & {}) member keeps the common values autocompletable while allowing genuinely opaque units.)
Can it be required? Yes.
Every activity has a billing unit that the adapter knows statically from its own pricing model — independent of whether the provider echoes a quantity:
| Activity |
Unit |
| chat / summarize / embedding |
tokens |
| generateTranscription |
seconds |
| generateSpeech (TTS) |
characters or seconds |
| generateAudio |
seconds |
| generateImage |
images / megapixels / units |
| generateVideo |
seconds |
Because the unit kind is always knowable, making billingUnit required is feasible and desirable — it forces every adapter to declare its unit rather than emit an ambiguous bare count. The 'units' / open-string members keep it honest for opaque providers, so "required" never forces a lie.
Caveats to settle in design
- Breaking change. Every adapter that constructs a
TokenUsage must set billingUnit. Coordinated migration across all adapter packages.
- Multi-dimensional billing. Chat bills input + output tokens (both
tokens, fine). Some media calls also charge input tokens on top of the media unit; billingUnit should describe the primary non-token billed unit while tokens remain in the token fields. Worth documenting.
- Alternative/complement: the unit is fundamentally a property of model pricing (
model-meta.ts), so it could additionally live there; echoing it on TokenUsage is what gives consumers the label at the point of use.
Acceptance
billingUnit added to TokenUsage (required) with the union above.
- All adapters set it for every activity.
- Media example reads
billingUnit for labels instead of guessing from cost presence.
- Gemini/fal/grok video adapters align on the same unit representation.
Problem
TokenUsagereports billed quantities but never the unit they're counted in. Today:unitsBilled?: numberis a bare count — the doc explicitly says "the unit itself (megapixels, seconds, images, …) is provider-defined and not carried here." (packages/ai-event-client/src/index.ts)durationSeconds?: numbercarries seconds, but only by convention/field name.A consumer that receives
unitsBilled: 5cannot tell whether that's 5 seconds of video, 5 images, or 5 megapixels. The unit is exposed nowhere machine-readable — the OTel middleware emitstanstack.ai.usage.units_billedunitless, and the only "seconds"/"images" strings in the codebase are hardcoded UI labels and JSDoc.This already bites the media example (
examples/ts-react-media/src/components/VideoGenerator.tsx), which has to guess the unit from a proxy signal — it branches on whethercostis present to decide between rendering "N seconds of video" vs "N fal units". That's fragile and only works because the two current providers happen to differ oncostpresence.It also drives inconsistency between adapters for the same concept: video length is reported as
durationSecondsby the Gemini video adapter butunitsBilledby the fal/grok video adapters.Proposal
Add a field that names the billed unit, alongside the quantity:
(Open to a better name —
billingUnit,billedUnit,usageUnit.billingUnitpairs cleanly with the existingunitsBilledquantity. The(string & {})member keeps the common values autocompletable while allowing genuinely opaque units.)Can it be required? Yes.
Every activity has a billing unit that the adapter knows statically from its own pricing model — independent of whether the provider echoes a quantity:
tokenssecondscharactersorsecondssecondsimages/megapixels/unitssecondsBecause the unit kind is always knowable, making
billingUnitrequired is feasible and desirable — it forces every adapter to declare its unit rather than emit an ambiguous bare count. The'units'/ open-string members keep it honest for opaque providers, so "required" never forces a lie.Caveats to settle in design
TokenUsagemust setbillingUnit. Coordinated migration across all adapter packages.tokens, fine). Some media calls also charge input tokens on top of the media unit;billingUnitshould describe the primary non-token billed unit while tokens remain in the token fields. Worth documenting.model-meta.ts), so it could additionally live there; echoing it onTokenUsageis what gives consumers the label at the point of use.Acceptance
billingUnitadded toTokenUsage(required) with the union above.billingUnitfor labels instead of guessing fromcostpresence.