Skip to content

Add a required billingUnit field to TokenUsage so billed quantities are self-describing #816

Description

@tombeckenham

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions