Skip to content

feat: add self-describing billed usage to TokenUsage#896

Open
season179 wants to merge 2 commits into
TanStack:mainfrom
season179:feat/billed-usage-unit
Open

feat: add self-describing billed usage to TokenUsage#896
season179 wants to merge 2 commits into
TanStack:mainfrom
season179:feat/billed-usage-unit

Conversation

@season179

@season179 season179 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #816.

Non-token billed quantities (seconds of audio/video, fal's endpoint units) are currently reported as bare counts on unitsBilled / durationSeconds, so consumers have to guess the unit from provider identity. This PR adds an optional structured billed field to TokenUsage that pairs the quantity with the unit it is denominated in:

usage.billed // { quantity: 8, unit: 'seconds' }

Design notes (where this deviates from the issue proposal)

The issue proposed a required flat billingUnit field. This PR uses an optional billed: { quantity, unit } object instead, for three reasons:

  • A flat unit field doesn't say which quantity it describes — durationSeconds and unitsBilled can coexist, so billingUnit: 'seconds' would still be ambiguous. The object shape forces quantity and unit to travel together.
  • Making it required would be a runtime lie: usage objects cross the SSE wire and server-function JSON boundaries, where version skew between client and server would surface undefined in a field typed as required. Optional matches reality — token-only activities simply don't set it (the token fields are already self-describing).
  • Required would also break every existing TokenUsage literal (~50 test files and any userland constructor) for no information gain.

BillingUnit is an open string union ('tokens' | 'seconds' | 'characters' | 'images' | 'videos' | 'megapixels' | 'requests' | 'units' | (string & {})) so common units autocomplete while provider-specific ones stay representable. 'units' marks an opaque provider-defined unit (fal's endpoint units, priced via fal's pricing API).

One premise of the issue didn't hold up: the Gemini video adapter reports no usage at all — its durationSeconds is request config sent to Veo, and the operation response carries no billing signal — so there was nothing to align there.

Changes

  • @tanstack/ai-event-client: BillingUnit + BilledUsage types; TokenUsage.billed; unitsBilled / durationSeconds deprecated (still populated). Re-exported from @tanstack/ai.
  • Producers: fal adapters report { quantity, unit: 'units' } from the x-fal-billable-units header; Grok Imagine video { quantity, unit: 'seconds' }; OpenAI (gpt-4o duration usage + whisper verbose_json) and Grok STT transcription { quantity, unit: 'seconds' }.
  • otelMiddleware: emits the pair as tanstack.ai.usage.billed_quantity / tanstack.ai.usage.billed_unit span attributes, guarded so a non-finite quantity never emits a dangling unit. Deprecated attributes still emitted for backward compatibility.
  • Example: ts-react-media labels billed usage from billed.unit instead of inferring from cost/provider.
  • Docs: media generation pages, OTel attribute tables (the units_billed attribute row was previously missing entirely), Grok adapter page, media-generation skill.

Tests

  • Unit tests across ai-fal / ai-grok / ai-openai / ai for the new field, including a NaN-guard case for the OTel attributes.
  • New E2E spec: drives generateTranscription (whisper-1, duration-billed) through otelMiddleware against the aimock fixture and asserts the billed span attributes end-to-end. The two ~100-line duplicated in-memory tracers in the otel E2E routes were deduplicated into a shared helper along the way.
  • Note: testing/e2e/fixtures/transcription/basic.json turned out to be a dead fixture — its userMessage match clause can never match because aimock builds transcription requests with empty messages. The live mock is the programmatic onTranscription in global-setup.ts, which now carries the duration the new spec asserts on. Left the dead fixture untouched; can remove it here or in a follow-up if preferred.

pnpm test:pr and the full E2E suite pass locally.

Summary by CodeRabbit

  • New Features
    • Usage reporting now includes explicit billed: { quantity, unit } for supported media and transcription results.
    • OpenTelemetry middleware now emits tanstack.ai.usage.billed_quantity and tanstack.ai.usage.billed_unit span attributes, including for duration-billed transcription.
  • Bug Fixes
    • Updated provider adapters to map billed seconds/units consistently, while continuing to populate deprecated legacy fields for compatibility.
  • Documentation
    • Refreshed docs and examples (fal/Grok/OpenAI) to use usage.billed instead of usage.unitsBilled.
  • Tests
    • Added/updated middleware E2E coverage for billed usage attributes.

Non-token billed quantities (seconds of audio/video, fal's endpoint
units) were previously reported as bare counts on unitsBilled /
durationSeconds, leaving consumers to guess the unit from provider
identity. TokenUsage now carries an optional billed field pairing the
quantity with the unit it is denominated in:

  usage.billed = { quantity: 8, unit: 'seconds' }

- BillingUnit is an open string union ('tokens' | 'seconds' | 'units' |
  ...) so provider-specific units stay representable while the common
  ones autocomplete.
- Producers updated: fal adapters ({ unit: 'units' } from the
  x-fal-billable-units header), Grok Imagine video ({ unit: 'seconds' }),
  and the OpenAI/Grok duration-billed transcription paths
  ({ unit: 'seconds' }).
- otelMiddleware emits the pair as tanstack.ai.usage.billed_quantity /
  billed_unit span attributes, guarded so a non-finite quantity never
  leaves a dangling unit.
- unitsBilled / durationSeconds are deprecated but still populated, so
  existing consumers keep working.

Closes TanStack#816
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c1c7ee5b-72a8-45b2-a71a-560370c2c261

📥 Commits

Reviewing files that changed from the base of the PR and between 3a53a16 and b106d0d.

📒 Files selected for processing (4)
  • packages/ai-openai/src/adapters/transcription.ts
  • testing/e2e/src/lib/request-body.ts
  • testing/e2e/src/routes/api.otel-media.ts
  • testing/e2e/src/routes/api.otel-transcription.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • testing/e2e/src/routes/api.otel-media.ts
  • packages/ai-openai/src/adapters/transcription.ts

📝 Walkthrough

Walkthrough

Adds usage.billed { quantity, unit } to TokenUsage, updates Fal/Grok/OpenAI usage mapping to populate it, emits new OTel billed span attributes, and refreshes docs, examples, and e2e transcription tracing coverage.

Changes

Billed usage rollout

Layer / File(s) Summary
Billing types and usage contract
packages/ai-event-client/src/index.ts, packages/ai/src/types.ts
Adds BillingUnit and BilledUsage, extends TokenUsage with billed, deprecates durationSeconds, and re-exports the new types.
Adapter usage mapping
packages/ai-fal/src/utils/billing.ts, packages/ai-fal/tests/*, packages/ai-grok/src/adapters/*, packages/ai-grok/tests/*, packages/ai-openai/src/adapters/transcription.ts, packages/ai-openai/tests/*
Fal, Grok, and OpenAI usage mapping now populate usage.billed, with tests updated to assert the new shape.
OTel usage attributes
packages/ai/src/middlewares/usage-attributes.ts, packages/ai/tests/middlewares/otel.test.ts
Emits tanstack.ai.usage.billed_quantity and tanstack.ai.usage.billed_unit when billed usage is present.
Docs and examples
docs/**, examples/ts-react-media/**, packages/ai/skills/ai-core/media-generation/SKILL.md, .changeset/billed-usage-unit.md, docs/config.json
Updates docs, examples, skill guidance, changeset metadata, and docs timestamps to describe usage.billed.
Shared e2e tracer and request parsing
testing/e2e/src/lib/otel-local-tracer.ts, testing/e2e/src/lib/request-body.ts, testing/e2e/src/routes/api.otel-media.ts, testing/e2e/src/routes/api.otel-usage.ts
Extracts shared tracer and request-body helpers and updates existing OTEL routes to import them.
OTEL transcription route and test
testing/e2e/src/routes/api.otel-transcription.ts, testing/e2e/src/routeTree.gen.ts, testing/e2e/global-setup.ts, testing/e2e/tests/middleware.spec.ts
Adds the transcription OTEL route, registers it in the route tree, seeds a transcription duration fixture, and verifies billed span attributes in e2e.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Test as Playwright test
  participant Route as /api/otel-transcription
  participant Adapter as Transcription adapter
  participant Middleware as otelMiddleware
  participant Tracer as Local capture tracer
  Test->>Route: POST audio, provider, testId
  Route->>Adapter: generateTranscription()
  Adapter->>Middleware: wrap transcription call
  Middleware->>Tracer: record billed quantity/unit
  Route-->>Test: { ok, spans }
Loading

Possibly related PRs

  • TanStack/ai#723: Both PRs update Fal usage handling and the TokenUsage shape for billable quantities.
  • TanStack/ai#742: Both PRs touch Grok video usage reporting.
  • TanStack/ai#747: Both PRs update OTEL usage attribute emission.

Suggested reviewers: AlemTuzlak, tombeckenham

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning [#816] The PR uses optional billed instead of the required billingUnit field and doesn't fully align all adapters with the issue's acceptance criteria. Add the required billingUnit field to TokenUsage, update all adapters (including Gemini), and align the media example/docs with that API.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and clearly summarizes the main change: self-describing billed usage on TokenUsage.
Description check ✅ Passed The description is detailed and covers summary, rationale, changes, and tests; only the template's checklist and release-impact sections are not filled in.
Out of Scope Changes check ✅ Passed The changes stay centered on billed-usage plumbing, docs, tests, and E2E support; no clearly unrelated changes stand out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
testing/e2e/src/routes/api.otel-transcription.ts (1)

8-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate isRecord/recordFromBody with api.otel-media.ts.

These two helpers are byte-for-byte duplicates of the ones in api.otel-media.ts. Given this PR just extracted createLocalCaptureTracer into a shared otel-local-tracer.ts specifically to eliminate this kind of duplication, it'd be consistent to hoist these two small parsing helpers into a shared module (e.g. @/lib/request-body.ts) as well.

♻️ Suggested extraction
// testing/e2e/src/lib/request-body.ts
export function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null
}

export function recordFromBody(body: unknown): Record<string, unknown> {
  if (!isRecord(body)) {
    throw new Error('Invalid request body')
  }

  const data = body.forwardedProps ?? body.data ?? body
  if (!isRecord(data)) {
    throw new Error('Invalid request body')
  }

  return data
}
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return typeof value === 'object' && value !== null
-}
-
-function recordFromBody(body: unknown): Record<string, unknown> {
-  if (!isRecord(body)) {
-    throw new Error('Invalid request body')
-  }
-
-  const data = body.forwardedProps ?? body.data ?? body
-  if (!isRecord(data)) {
-    throw new Error('Invalid request body')
-  }
-
-  return data
-}
+import { recordFromBody } from '`@/lib/request-body`'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@testing/e2e/src/routes/api.otel-transcription.ts` around lines 8 - 23, The
`isRecord` and `recordFromBody` helpers in `api.otel-transcription.ts` are
duplicated in `api.otel-media.ts`; extract them into a shared request-body
utility module and import them from both routes. Keep the shared implementation
consistent with the current `recordFromBody` behavior, including the
`forwardedProps`/`data` fallback and validation logic, so both route handlers
reuse the same parsing helpers.
packages/ai-openai/src/adapters/transcription.ts (1)

74-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract a shared helper for the duration-billed TokenUsage shape.

Both branches build the identical zeroed-tokens + billed/durationSeconds object, differing only in the source value (usage.seconds vs duration). Consolidating avoids drift between billed and the deprecated durationSeconds field as this compatibility shim evolves.

♻️ Proposed refactor
+function buildDurationBilledUsage(seconds: number): TokenUsage {
+  return {
+    promptTokens: 0,
+    completionTokens: 0,
+    totalTokens: 0,
+    billed: { quantity: seconds, unit: 'seconds' },
+    durationSeconds: seconds,
+  }
+}
+
 function buildTranscriptionUsage(
   model: string,
   duration?: number,
   response?: OpenAI_SDK.Audio.TranscriptionCreateResponse,
 ): TokenUsage | undefined {
   ...
     if (usage.type === 'duration') {
-      return {
-        promptTokens: 0,
-        completionTokens: 0,
-        totalTokens: 0,
-        billed: { quantity: usage.seconds, unit: 'seconds' },
-        durationSeconds: usage.seconds,
-      }
+      return buildDurationBilledUsage(usage.seconds)
     }
   ...
   if (duration !== undefined && duration > 0) {
-    return {
-      promptTokens: 0,
-      completionTokens: 0,
-      totalTokens: 0,
-      billed: { quantity: duration, unit: 'seconds' },
-      durationSeconds: duration,
-    }
+    return buildDurationBilledUsage(duration)
   }

Also applies to: 113-122

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-openai/src/adapters/transcription.ts` around lines 74 - 82, The
duration-billed TokenUsage object is duplicated in the transcription adapter,
with only the source value changing between usage.seconds and duration. Extract
a shared helper in transcription.ts around the TokenUsage construction used by
the duration branch (and the other matching branch mentioned in the review) so
both paths reuse the same zeroed-tokens plus billed/durationSeconds shape. Keep
the helper focused on the shared compatibility-shim fields to prevent drift
between billed and durationSeconds as the code evolves.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/ai-openai/src/adapters/transcription.ts`:
- Around line 74-82: The duration-billed TokenUsage object is duplicated in the
transcription adapter, with only the source value changing between usage.seconds
and duration. Extract a shared helper in transcription.ts around the TokenUsage
construction used by the duration branch (and the other matching branch
mentioned in the review) so both paths reuse the same zeroed-tokens plus
billed/durationSeconds shape. Keep the helper focused on the shared
compatibility-shim fields to prevent drift between billed and durationSeconds as
the code evolves.

In `@testing/e2e/src/routes/api.otel-transcription.ts`:
- Around line 8-23: The `isRecord` and `recordFromBody` helpers in
`api.otel-transcription.ts` are duplicated in `api.otel-media.ts`; extract them
into a shared request-body utility module and import them from both routes. Keep
the shared implementation consistent with the current `recordFromBody` behavior,
including the `forwardedProps`/`data` fallback and validation logic, so both
route handlers reuse the same parsing helpers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 23e53528-e844-4931-bd7e-4969133f4998

📥 Commits

Reviewing files that changed from the base of the PR and between 5deda27 and 3a53a16.

📒 Files selected for processing (36)
  • .changeset/billed-usage-unit.md
  • docs/adapters/grok.md
  • docs/advanced/otel.md
  • docs/config.json
  • docs/media/audio-generation.md
  • docs/media/image-generation.md
  • docs/media/video-generation.md
  • examples/ts-react-media/src/components/ImageGenerator.tsx
  • examples/ts-react-media/src/components/VideoGenerator.tsx
  • examples/ts-react-media/src/lib/server-functions.ts
  • packages/ai-event-client/src/index.ts
  • packages/ai-fal/src/utils/billing.ts
  • packages/ai-fal/tests/audio-adapter.test.ts
  • packages/ai-fal/tests/billing.test.ts
  • packages/ai-fal/tests/image-adapter.test.ts
  • packages/ai-fal/tests/speech-adapter.test.ts
  • packages/ai-fal/tests/transcription-adapter.test.ts
  • packages/ai-fal/tests/video-adapter.test.ts
  • packages/ai-grok/src/adapters/transcription.ts
  • packages/ai-grok/src/adapters/video.ts
  • packages/ai-grok/tests/audio-adapters.test.ts
  • packages/ai-grok/tests/video-adapter.test.ts
  • packages/ai-openai/src/adapters/transcription.ts
  • packages/ai-openai/tests/transcription-adapter.test.ts
  • packages/ai-openai/tests/transcription-usage.test.ts
  • packages/ai/skills/ai-core/media-generation/SKILL.md
  • packages/ai/src/middlewares/usage-attributes.ts
  • packages/ai/src/types.ts
  • packages/ai/tests/middlewares/otel.test.ts
  • testing/e2e/global-setup.ts
  • testing/e2e/src/lib/otel-local-tracer.ts
  • testing/e2e/src/routeTree.gen.ts
  • testing/e2e/src/routes/api.otel-media.ts
  • testing/e2e/src/routes/api.otel-transcription.ts
  • testing/e2e/src/routes/api.otel-usage.ts
  • testing/e2e/tests/middleware.spec.ts

- Extract durationUsage() in the OpenAI transcription adapter so the
  gpt-4o duration branch and the whisper-1 path share one zeroed-tokens +
  billed/durationSeconds shape and can't drift apart.
- Move the identical recordFromBody() body-parsing helper from the
  otel-media and otel-transcription e2e routes into a shared
  lib/request-body module.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant