Skip to content

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

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

feat: add self-describing billed usage to TokenUsage#896
season179 wants to merge 3 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 supports explicit billed: { quantity, unit } for media and transcription results (with legacy billed fields still populated).
    • OpenTelemetry middleware emits tanstack.ai.usage.billed_quantity / tanstack.ai.usage.billed_unit, including for duration-billed transcription.
  • Bug Fixes
    • Provider adapters were updated to map billed seconds/units consistently across transcription and video (while preserving backward compatibility).
  • Documentation
    • Updated docs and examples to use usage.billed instead of usage.unitsBilled.
  • Tests
    • Added/expanded middleware E2E coverage for billed usage span attributes via /api/otel-transcription.

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: 5e5ac2f6-bb1d-47cb-95e6-71e040118160

📥 Commits

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

📒 Files selected for processing (6)
  • docs/config.json
  • docs/media/image-generation.md
  • packages/ai/skills/ai-core/media-generation/SKILL.md
  • packages/ai/src/types.ts
  • testing/e2e/global-setup.ts
  • testing/e2e/src/routes/api.otel-usage.ts
✅ Files skipped from review due to trivial changes (2)
  • docs/media/image-generation.md
  • docs/config.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • testing/e2e/global-setup.ts
  • packages/ai/src/types.ts
  • testing/e2e/src/routes/api.otel-usage.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 PlaywrightTest
  participant OtelTranscriptionRoute
  participant TranscriptionAdapter
  participant OtelMiddleware
  participant LocalCaptureTracer
  PlaywrightTest->>OtelTranscriptionRoute: POST audio, provider, testId
  OtelTranscriptionRoute->>TranscriptionAdapter: generateTranscription()
  TranscriptionAdapter->>OtelMiddleware: wrap transcription call
  OtelMiddleware->>LocalCaptureTracer: record billed quantity/unit
  OtelTranscriptionRoute-->>PlaywrightTest: { 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 The PR uses an optional billed object instead of the required billingUnit field requested in #816, so it does not meet the linked issue acceptance. Align TokenUsage with #816 by adding the required billingUnit field (or revise the issue scope) and update adapters, docs, and tests accordingly.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main TokenUsage billing change.
Description check ✅ Passed The description covers the change, rationale, testing, and release impact, though the template checklist sections are not fully filled out.
Out of Scope Changes check ✅ Passed The extra docs, changeset, and E2E scaffolding support the billed-usage update and do not appear unrelated to the PR goals.
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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

testing/e2e/global-setup.ts

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): testing/e2e/global-setup.ts


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.
@AlemTuzlak

Copy link
Copy Markdown
Contributor

@tombeckenham leaving this one to you!

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

2 participants