diff --git a/.changeset/billed-usage-unit.md b/.changeset/billed-usage-unit.md
new file mode 100644
index 0000000000..41aeedbe40
--- /dev/null
+++ b/.changeset/billed-usage-unit.md
@@ -0,0 +1,10 @@
+---
+'@tanstack/ai-event-client': minor
+'@tanstack/ai': minor
+'@tanstack/ai-fal': minor
+'@tanstack/ai-grok': minor
+'@tanstack/ai-openai': minor
+'@tanstack/ai-byteplus': minor
+---
+
+Add a self-describing `billed` field to `TokenUsage` so non-token billed quantities carry the unit they are counted in (#816). `usage.billed` is `{ quantity, unit }` with a `BillingUnit` union (`'seconds'`, `'units'`, `'images'`, ... open-ended), replacing the guesswork previously needed to interpret the bare `unitsBilled` / `durationSeconds` counts — those two fields are now deprecated but still populated for backward compatibility. The fal adapters report `{ quantity, unit: 'units' }`, Grok video `{ quantity, unit: 'seconds' }`, the OpenAI/Grok/BytePlus duration-billed transcription paths `{ quantity, unit: 'seconds' }`, and BytePlus Seedream images `{ quantity, unit: 'images' }`. `otelMiddleware` emits the pair as `tanstack.ai.usage.billed_quantity` / `tanstack.ai.usage.billed_unit` span attributes.
diff --git a/docs/adapters/grok.md b/docs/adapters/grok.md
index 832ac99866..a31756cb27 100644
--- a/docs/adapters/grok.md
+++ b/docs/adapters/grok.md
@@ -292,7 +292,7 @@ const { jobId } = await generateVideo({
Like the Grok Imagine image models, sizing is aspect-ratio based: the `size` option takes an `aspectRatio_resolution` template. Supported aspect ratios are `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, and `2:3`; supported resolutions are `480p`, `720p`, and `1080p` (e.g. `"9:16_1080p"`). The resolution suffix is optional.
-When the job completes, the adapter reports usage on the result: `usage.unitsBilled` carries the billed seconds of video and `usage.cost` the exact cost in USD, both as returned by the xAI API.
+When the job completes, the adapter reports usage on the result: `usage.billed` carries the billed seconds of video (`{ quantity, unit: 'seconds' }`) and `usage.cost` the exact cost in USD, both as returned by the xAI API.
See [Video Generation](../media/video-generation) for the full jobs/polling flow, streaming mode, and the `useGenerateVideo` hook.
diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md
index a92cae8019..774c09b5ee 100644
--- a/docs/advanced/otel.md
+++ b/docs/advanced/otel.md
@@ -79,7 +79,10 @@ Iteration spans are numbered (`#0`, `#1`, ...) in the order model calls are obse
| root / iteration | `gen_ai.usage.cache_read.input_tokens` | cached prompt tokens, when reported |
| root / iteration | `gen_ai.usage.cache_creation.input_tokens` | cache-write prompt tokens, when reported |
| root / iteration | `gen_ai.usage.reasoning.output_tokens` | reasoning/thinking tokens, when reported |
-| root / iteration | `tanstack.ai.usage.duration_seconds` | duration-based billing (e.g. transcription), when reported |
+| root / iteration | `tanstack.ai.usage.billed_quantity` | non-token billed quantity, when reported |
+| root / iteration | `tanstack.ai.usage.billed_unit` | unit of the billed quantity (`seconds`, `units`, ...) |
+| root / iteration | `tanstack.ai.usage.duration_seconds` | deprecated duration count; read `billed_quantity`/`billed_unit` instead |
+| root / iteration | `tanstack.ai.usage.units_billed` | deprecated bare unit count; read `billed_quantity`/`billed_unit` instead |
| root / iteration | `tanstack.ai.usage.upstream_cost` | gateway upstream cost (e.g. OpenRouter), when reported |
| root / iteration | `tanstack.ai.usage.upstream_input_cost` | upstream input cost split, when reported |
| root / iteration | `tanstack.ai.usage.upstream_output_cost` | upstream output cost split, when reported |
@@ -92,7 +95,9 @@ Iteration spans are numbered (`#0`, `#1`, ...) in the order model calls are obse
| tool | `gen_ai.tool.type` | `function` |
| tool | `tanstack.ai.tool.outcome` | `success` / `error` |
-Usage attributes beyond input/output tokens are emitted only when the provider reports them, so spans stay clean otherwise. Cache and reasoning breakdowns use the official GenAI semconv names; `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions consumed directly by backends like PostHog — without them, backends re-derive cost from their own price tables and lose cache discounts and gateway markup. Fields with no established convention (duration-based billing, the upstream cost split) are TanStack-namespaced.
+Usage attributes beyond input/output tokens are emitted only when the provider reports them, so spans stay clean otherwise. Cache and reasoning breakdowns use the official GenAI semconv names; `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions consumed directly by backends like PostHog — without them, backends re-derive cost from their own price tables and lose cache discounts and gateway markup. Fields with no established convention (the billed quantity/unit pair, the upstream cost split, and the deprecated bare counts) are TanStack-namespaced.
+
+For non-token billing (seconds of video or transcription, fal's endpoint units, ...), `tanstack.ai.usage.billed_quantity` and `tanstack.ai.usage.billed_unit` are emitted as a pair from `usage.billed`, so backends can label and aggregate media usage without knowing the provider. The deprecated `duration_seconds` / `units_billed` attributes carry the same quantities without the unit and remain emitted for backward compatibility.
### Metrics
@@ -234,7 +239,7 @@ Each media call produces one `CLIENT` span tagged with the activity's `gen_ai.op
| `generateTranscription` | `transcription` |
| `summarize` | `summarize` |
-The span carries `gen_ai.system` and `gen_ai.request.model` at start and, on finish, the same `gen_ai.usage.*` / `tanstack.ai.usage.*` attributes documented above — including `tanstack.ai.usage.units_billed` for unit-billed media. When a `Meter` is supplied it records the `gen_ai.client.operation.duration` histogram, tagged per activity. For streaming video the span covers the full create → poll → complete lifecycle. Non-streaming video is two calls, so the submit itself emits no span — the run opens once the provider accepts the job, and the `getVideoJobStatus()` poll that observes a terminal state ends it. If a streaming video consumer abandons the stream before completion, the span is ended via `onAbort` (status `ERROR`, `tanstack.ai.completion.reason = cancelled`) rather than leaked.
+The span carries `gen_ai.system` and `gen_ai.request.model` at start and, on finish, the same `gen_ai.usage.*` / `tanstack.ai.usage.*` attributes documented above — including the `tanstack.ai.usage.billed_quantity` / `tanstack.ai.usage.billed_unit` pair for unit-billed media. When a `Meter` is supplied it records the `gen_ai.client.operation.duration` histogram, tagged per activity. For streaming video the span covers the full create → poll → complete lifecycle. Non-streaming video is two calls, so the submit itself emits no span — the run opens once the provider accepts the job, and the `getVideoJobStatus()` poll that observes a terminal state ends it. If a streaming video consumer abandons the stream before completion, the span is ended via `onAbort` (status `ERROR`, `tanstack.ai.completion.reason = cancelled`) rather than leaked.
`otelMiddleware` applies the same `spanNameFormatter`, `attributeEnricher`, `onBeforeSpanStart`, and `onSpanEnd` extension points to media spans — the span info is discriminated by `kind`, where media spans report `kind: 'generation'`. For a custom backend, implement the base `GenerationMiddleware` contract directly; its hooks (`onStart` / `onUsage` / `onFinish` / `onAbort` / `onError`) receive the `GenerationMiddlewareContext` and fire for every activity, chat included. The `GenerationMiddleware` types are exported from the package root, while the `otelMiddleware` value lives on the `@tanstack/ai/middlewares/otel` subpath so importing `@tanstack/ai` never requires the optional `@opentelemetry/api` peer.
diff --git a/docs/config.json b/docs/config.json
index e5c98bc877..ae8c129f9c 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -431,7 +431,7 @@
"label": "Audio Generation",
"to": "media/audio-generation",
"addedAt": "2026-04-23",
- "updatedAt": "2026-08-04"
+ "updatedAt": "2026-08-08"
},
{
"label": "Image Generation",
@@ -498,7 +498,7 @@
"label": "OpenTelemetry",
"to": "advanced/otel",
"addedAt": "2026-05-08",
- "updatedAt": "2026-08-06"
+ "updatedAt": "2026-08-08"
}
]
},
@@ -808,7 +808,7 @@
"label": "Grok (xAI)",
"to": "adapters/grok",
"addedAt": "2026-04-15",
- "updatedAt": "2026-06-24"
+ "updatedAt": "2026-08-02"
},
{
"label": "Groq",
diff --git a/docs/media/audio-generation.md b/docs/media/audio-generation.md
index eac039a5ee..3cdb89029d 100644
--- a/docs/media/audio-generation.md
+++ b/docs/media/audio-generation.md
@@ -199,9 +199,10 @@ interface AudioGenerationResult {
}
// Canonical TokenUsage (same shape as chat), present when the provider
// reports it (e.g. Gemini Lyria via generateContent). Usage-billed providers
- // (fal) instead surface `usage.unitsBilled` — the real billed quantity read
- // from fal's `x-fal-billable-units` result header. Multiply by the endpoint's
- // unit price (fal pricing API) for the exact cost.
+ // (fal) instead surface `usage.billed` ({ quantity, unit: 'units' }) — the
+ // real billed quantity read from fal's `x-fal-billable-units` result header.
+ // Multiply the quantity by the endpoint's unit price (fal pricing API) for
+ // the exact cost.
usage?: TokenUsage
}
```
diff --git a/docs/media/image-generation.md b/docs/media/image-generation.md
index c1312e8122..c688657254 100644
--- a/docs/media/image-generation.md
+++ b/docs/media/image-generation.md
@@ -661,7 +661,7 @@ interface ImageGenerationResult {
// Canonical TokenUsage (same shape as chat). Token-billed models also surface
// a per-modality breakdown on `promptTokensDetails` (e.g. text vs image input
// tokens for gpt-image-1). Usage-billed providers (fal) instead surface
- // `usage.unitsBilled` — see the note below.
+ // `usage.billed` ({ quantity, unit }) — see the note below.
usage?: TokenUsage;
}
@@ -673,9 +673,9 @@ interface GeneratedImage {
```
> **Cost tracking (fal):** fal bills by usage-based units rather than tokens. The
-> fal image adapter surfaces the real billed quantity as `usage.unitsBilled`
-> (read from fal's `x-fal-billable-units` result header). Multiply it by the
-> endpoint's unit price from
+> fal image adapter surfaces the real billed quantity as `usage.billed` —
+> `{ quantity, unit: 'units' }`, read from fal's `x-fal-billable-units` result
+> header. Multiply the quantity by the endpoint's unit price from
> `GET https://api.fal.ai/v1/models/pricing?endpoint_id=…` for the exact cost —
> no `fetch` interceptor needed.
@@ -689,9 +689,10 @@ const result = await generateImage({
prompt: "a serene mountain lake",
});
-if (result.usage?.unitsBilled != null) {
- const cost = result.usage.unitsBilled * unitPrice; // unitPrice from fal pricing API
- console.log(`Billed ${result.usage.unitsBilled} units (~$${cost})`);
+if (result.usage?.billed) {
+ const { quantity, unit } = result.usage.billed;
+ const cost = quantity * unitPrice; // unitPrice from fal pricing API
+ console.log(`Billed ${quantity} ${unit} (~$${cost})`);
}
```
diff --git a/docs/media/video-generation.md b/docs/media/video-generation.md
index a75f0f7909..fa47fc7e4f 100644
--- a/docs/media/video-generation.md
+++ b/docs/media/video-generation.md
@@ -745,7 +745,7 @@ adapter.snapDuration(2.5); // 3 — clamped/rounded into range
adapter.snapDuration(99); // 15
```
-Generated clips include an audio track. When the job completes, the adapter reports `usage.unitsBilled` (billed seconds of video) and `usage.cost` (exact USD cost as returned by the API) on the result.
+Generated clips include an audio track. When the job completes, the adapter reports `usage.billed` (`{ quantity, unit: 'seconds' }` — billed seconds of video) and `usage.cost` (exact USD cost as returned by the API) on the result.
#### BytePlus (Seedance) Model Options
@@ -880,19 +880,24 @@ interface VideoUrlResult {
jobId: string;
url: string; // URL to download/stream the video
expiresAt?: Date; // When the URL expires
- // Usage for the completed generation, when the adapter reports it. fal
- // populates `usage.unitsBilled` from its `x-fal-billable-units` header.
+ // Usage for the completed generation, when the adapter reports it. The
+ // billed quantity is self-describing: fal reports
+ // `usage.billed = { quantity, unit: 'units' }` (from its
+ // `x-fal-billable-units` header), Grok Imagine reports
+ // `{ quantity, unit: 'seconds' }`.
usage?: TokenUsage;
}
```
> **Cost tracking (fal):** fal bills media generation by usage-based units
> rather than tokens. The fal adapters surface the real billed quantity as
-> `usage.unitsBilled` (denominated in the endpoint's priced unit). Combine it
-> with the endpoint's unit price from
-> `GET https://api.fal.ai/v1/models/pricing?endpoint_id=…` to compute the exact
-> cost (`unitsBilled * unitPrice`). The same `usage.unitsBilled` is surfaced
-> on image, audio, speech, and transcription results.
+> `usage.billed` — `{ quantity, unit: 'units' }`, where `'units'` marks fal's
+> endpoint-defined priced unit. Combine the quantity with the endpoint's unit
+> price from `GET https://api.fal.ai/v1/models/pricing?endpoint_id=…` to
+> compute the exact cost (`billed.quantity * unitPrice`). The same
+> `usage.billed` is surfaced on image, audio, speech, and transcription
+> results. (The deprecated bare count `usage.unitsBilled` is still populated
+> for backward compatibility.)
### Model Variants
diff --git a/examples/ts-react-media/src/components/ImageGenerator.tsx b/examples/ts-react-media/src/components/ImageGenerator.tsx
index d1af512976..af18858fbc 100644
--- a/examples/ts-react-media/src/components/ImageGenerator.tsx
+++ b/examples/ts-react-media/src/components/ImageGenerator.tsx
@@ -354,12 +354,13 @@ function ImageModelCard({
className="w-full h-auto"
/>
- {result?.usage?.unitsBilled != null && (
+ {result?.usage?.billed && (
- Billed {result.usage.unitsBilled}{' '}
- {model.provider === 'fal' ? 'fal ' : ''}unit
- {result.usage.unitsBilled === 1 ? '' : 's'} — multiply by the
- endpoint unit price for USD cost
+ Billed {result.usage.billed.quantity}{' '}
+ {result.usage.billed.unit === 'units'
+ ? `fal unit${result.usage.billed.quantity === 1 ? '' : 's'}`
+ : result.usage.billed.unit}{' '}
+ — multiply by the endpoint unit price for USD cost
)}
>
diff --git a/examples/ts-react-media/src/components/SeedanceStudio.tsx b/examples/ts-react-media/src/components/SeedanceStudio.tsx
index 1c585029d1..648d7bfcc0 100644
--- a/examples/ts-react-media/src/components/SeedanceStudio.tsx
+++ b/examples/ts-react-media/src/components/SeedanceStudio.tsx
@@ -1238,10 +1238,10 @@ export default function SeedanceStudio({
value={formatElapsed(finishedAt - startedAt)}
/>
)}
- {billing && (
+ {billing?.totalTokens != null && (
)}
diff --git a/examples/ts-react-media/src/components/VideoGenerator.tsx b/examples/ts-react-media/src/components/VideoGenerator.tsx
index 653cd29f24..989a0c84db 100644
--- a/examples/ts-react-media/src/components/VideoGenerator.tsx
+++ b/examples/ts-react-media/src/components/VideoGenerator.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Film, Loader2, Shuffle, Upload, Wand2, X } from 'lucide-react'
import { useGenerateVideo } from '@tanstack/ai-react'
+import type { BilledUsage } from '@tanstack/ai'
import type { VideoModel, VideoMode } from '@/lib/models'
import type { AttachedMedia } from '@/lib/media'
import type { MediaPrompt, MediaPromptPart } from '@tanstack/ai/client'
@@ -57,6 +58,22 @@ function buildVideoPrompt(
return parts.length === 1 ? request.prompt : parts
}
+/**
+ * Human label for a billed quantity, driven by the unit the adapter reported —
+ * no guessing from provider identity or cost presence.
+ */
+function describeBilled({ quantity, unit }: BilledUsage): string {
+ const plural = quantity === 1 ? '' : 's'
+ switch (unit) {
+ case 'seconds':
+ return `${quantity} second${plural} of video`
+ case 'units':
+ return `${quantity} fal unit${plural}`
+ default:
+ return `${quantity} ${unit}`
+ }
+}
+
export default function VideoGenerator({
initialImageUrl,
}: VideoGeneratorProps) {
@@ -559,16 +576,15 @@ function VideoModelCard({
{billing?.cost != null ? (
Billed ${billing.cost.toFixed(3)}
- {billing.unitsBilled != null
- ? ` for ${billing.unitsBilled} second${billing.unitsBilled === 1 ? '' : 's'} of video`
- : ''}
+ {billing.billed ? ` for ${describeBilled(billing.billed)}` : ''}
) : (
- billing?.unitsBilled != null && (
+ billing?.billed && (
- Billed {billing.unitsBilled} fal unit
- {billing.unitsBilled === 1 ? '' : 's'} — multiply by the
- endpoint unit price for USD cost
+ Billed {describeBilled(billing.billed)}
+ {billing.billed.unit === 'units'
+ ? ' — multiply by the endpoint unit price for USD cost'
+ : ''}
)
)}
diff --git a/examples/ts-react-media/src/lib/billing.ts b/examples/ts-react-media/src/lib/billing.ts
index 0615de6487..a56fdf34cd 100644
--- a/examples/ts-react-media/src/lib/billing.ts
+++ b/examples/ts-react-media/src/lib/billing.ts
@@ -1,4 +1,4 @@
-import type { StreamChunk } from '@tanstack/ai'
+import type { BilledUsage, StreamChunk } from '@tanstack/ai'
/**
* Billing figures a finished video job reports. `VideoGenerateResult` — what
@@ -7,8 +7,8 @@ import type { StreamChunk } from '@tanstack/ai'
* through the hook's `onChunk` instead.
*/
export interface VideoBilling {
- /** Priced units billed — fal units, or seconds of video on xAI Imagine. */
- unitsBilled?: number
+ /** Billed quantity paired with the unit it is denominated in. */
+ billed?: BilledUsage
/** Provider-reported cost in USD, for providers that report one. */
cost?: number
/** Token total, for providers that bill media generation as tokens. */
@@ -20,6 +20,18 @@ function numberField(source: object, key: string): number | undefined {
return typeof value === 'number' ? value : undefined
}
+/** Reads `usage.billed` when it carries the `{ quantity, unit }` pair. */
+function billedField(source: object): BilledUsage | undefined {
+ const value: unknown = Reflect.get(source, 'billed')
+ if (typeof value !== 'object' || value === null) return undefined
+ const quantity: unknown = Reflect.get(value, 'quantity')
+ const unit: unknown = Reflect.get(value, 'unit')
+ if (typeof quantity !== 'number' || typeof unit !== 'string') {
+ return undefined
+ }
+ return { quantity, unit }
+}
+
/**
* Reads the usage block off a generation's terminal result chunk, or
* `undefined` for every other chunk (and for providers that report no usage).
@@ -33,18 +45,14 @@ export function readVideoBilling(chunk: StreamChunk): VideoBilling | undefined {
const usage: unknown = Reflect.get(value, 'usage')
if (typeof usage !== 'object' || usage === null) return undefined
- const unitsBilled = numberField(usage, 'unitsBilled')
+ const billed = billedField(usage)
const cost = numberField(usage, 'cost')
const totalTokens = numberField(usage, 'totalTokens')
- if (
- unitsBilled === undefined &&
- cost === undefined &&
- totalTokens === undefined
- ) {
+ if (billed === undefined && cost === undefined && totalTokens === undefined) {
return undefined
}
return {
- ...(unitsBilled !== undefined && { unitsBilled }),
+ ...(billed !== undefined && { billed }),
...(cost !== undefined && { cost }),
...(totalTokens !== undefined && { totalTokens }),
}
diff --git a/examples/ts-react-media/src/lib/server-functions.ts b/examples/ts-react-media/src/lib/server-functions.ts
index aa0f12c07a..7544e7ec39 100644
--- a/examples/ts-react-media/src/lib/server-functions.ts
+++ b/examples/ts-react-media/src/lib/server-functions.ts
@@ -328,8 +328,8 @@ function videoStreamForModel(data: VideoRequest): AsyncIterable {
case 'grok-imagine-video': {
// Direct xAI Imagine API (XAI_API_KEY) — no fal in between. The base
// grok-imagine-video (v1.0) supports text-to-video; durations are
- // 1-15 integer seconds. Completed jobs report usage.unitsBilled
- // (billed seconds) and usage.cost (exact USD).
+ // 1-15 integer seconds. Completed jobs report usage.billed
+ // ({ quantity, unit: 'seconds' }) and usage.cost (exact USD).
return generateVideo({
stream: true,
pollingInterval: VIDEO_POLL_INTERVAL_MS,
diff --git a/packages/ai-byteplus/src/adapters/image.ts b/packages/ai-byteplus/src/adapters/image.ts
index 76e2d52b37..1680fb1c0c 100644
--- a/packages/ai-byteplus/src/adapters/image.ts
+++ b/packages/ai-byteplus/src/adapters/image.ts
@@ -94,7 +94,9 @@ function describeFailures(
*
* BytePlus bills per generated image and does not count input tokens, so
* `promptTokens` is always 0 and `generated_images` is surfaced as
- * `unitsBilled` — the count the price is applied to.
+ * `usage.billed` (`{ quantity, unit: 'images' }`) — the count the price is
+ * applied to. The deprecated `unitsBilled` is still populated for
+ * backward compatibility.
*/
function buildBytePlusImageUsage(
usage: BytePlusImageUsage | undefined,
@@ -107,6 +109,7 @@ function buildBytePlusImageUsage(
completionTokens,
totalTokens: usage.total_tokens ?? completionTokens,
...(usage.generated_images !== undefined && {
+ billed: { quantity: usage.generated_images, unit: 'images' },
unitsBilled: usage.generated_images,
}),
}
diff --git a/packages/ai-byteplus/src/adapters/transcription.ts b/packages/ai-byteplus/src/adapters/transcription.ts
index f1b68fffb8..4a57f042ca 100644
--- a/packages/ai-byteplus/src/adapters/transcription.ts
+++ b/packages/ai-byteplus/src/adapters/transcription.ts
@@ -316,13 +316,15 @@ export function mapRecognizeResponse(
// Seed ASR is duration-billed and reports no token counts, so `usage`
// carries only the audio length — the same shape the Grok and OpenAI
- // whisper paths use.
+ // whisper paths use. `durationSeconds` is deprecated but still populated
+ // alongside the self-describing `billed` pair.
const usage: TokenUsage | undefined =
duration !== undefined
? {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: duration, unit: 'seconds' },
durationSeconds: duration,
}
: undefined
diff --git a/packages/ai-byteplus/tests/image.test.ts b/packages/ai-byteplus/tests/image.test.ts
index cba0ad432a..625eeb1b28 100644
--- a/packages/ai-byteplus/tests/image.test.ts
+++ b/packages/ai-byteplus/tests/image.test.ts
@@ -501,6 +501,7 @@ describe('response mapping', () => {
promptTokens: 0,
completionTokens: 3888,
totalTokens: 3888,
+ billed: { quantity: 1, unit: 'images' },
unitsBilled: 1,
})
})
@@ -543,6 +544,7 @@ describe('response mapping', () => {
{ b64Json: 'QUJD' },
])
expect(result.usage?.unitsBilled).toBe(2)
+ expect(result.usage?.billed).toEqual({ quantity: 2, unit: 'images' })
})
it('keeps the successes when part of a group fails, and reports the rest', async () => {
diff --git a/packages/ai-byteplus/tests/transcription.test.ts b/packages/ai-byteplus/tests/transcription.test.ts
index a7ff8076a3..6bf39bde7f 100644
--- a/packages/ai-byteplus/tests/transcription.test.ts
+++ b/packages/ai-byteplus/tests/transcription.test.ts
@@ -132,6 +132,7 @@ describe('BytePlusTranscriptionAdapter', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 2.499, unit: 'seconds' },
durationSeconds: 2.499,
})
expect(result.id).toMatch(/^byteplus-/)
diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts
index 58d2c004de..37546bbcfe 100644
--- a/packages/ai-event-client/src/index.ts
+++ b/packages/ai-event-client/src/index.ts
@@ -185,6 +185,36 @@ export interface UsageCostBreakdown {
upstreamOutputCost?: number
}
+/**
+ * Unit a billed quantity is counted in. The named members cover the units
+ * TanStack AI adapters bill in today; the `(string & {})` member keeps the
+ * union open for genuinely provider-specific units while preserving
+ * autocompletion for the common ones.
+ */
+export type BillingUnit =
+ | 'tokens'
+ | 'seconds'
+ | 'characters'
+ | 'images'
+ | 'videos'
+ | 'megapixels'
+ | 'requests'
+ | 'units'
+ | (string & {})
+
+/**
+ * A billed quantity paired with the unit it is counted in, so consumers can
+ * label and aggregate usage without out-of-band knowledge of the provider or
+ * activity. `unit: 'units'` marks an opaque provider-defined unit (e.g. fal's
+ * "fal units") whose price is only knowable from the provider's pricing page.
+ */
+export interface BilledUsage {
+ /** Number of units billed. */
+ quantity: number
+ /** The unit `quantity` is counted in. */
+ unit: BillingUnit
+}
+
/**
* Default value type for {@link TokenUsage.providerUsageDetails} when an adapter
* does not supply a specific shape. Values are constrained to non-nullish
@@ -221,18 +251,27 @@ export interface TokenUsage {
promptTokensDetails?: PromptTokensDetails
/** Detailed breakdown of completion tokens by category */
completionTokensDetails?: CompletionTokensDetails
- /** Duration in seconds for duration-based billing (e.g., Whisper transcription) */
+ /**
+ * The primary non-token billed quantity, self-describing via its unit —
+ * e.g. `{ quantity: 8, unit: 'seconds' }` for a video generation or
+ * `{ quantity: 3, unit: 'units' }` for fal's opaque endpoint units. Absent
+ * when the activity bills purely in tokens (the token fields above are
+ * already self-describing). When a provider bills tokens *on top of* a media
+ * unit, the tokens stay in the token fields and `billed` carries the media
+ * unit. A quantity, distinct from the monetary `cost` / `costDetails`.
+ */
+ billed?: BilledUsage
+ /**
+ * @deprecated Read {@link TokenUsage.billed} instead, which pairs the same
+ * duration with an explicit `unit: 'seconds'`. Still populated alongside
+ * `billed` for backward compatibility; will be removed in a future release.
+ */
durationSeconds?: number
/**
- * Number of priced units actually billed, for usage-based (non-token) billing.
- * This is a bare count, not a cost and not a unit name — the unit itself
- * (megapixels, seconds, images, …) is provider-defined and not carried here;
- * providers typically expose it via a separate pricing API. Surfaced for media
- * generation, where there are no tokens: fal returns this count in its
- * `x-fal-billable-units` response header. Multiply by the unit price to get the
- * exact cost (`unitsBilled * unitPrice`). The unit-priced analogue of
- * `durationSeconds` (the time-priced case); both are quantities, distinct from
- * the monetary `cost` / `costDetails`.
+ * @deprecated Read {@link TokenUsage.billed} instead, which pairs the same
+ * count with the unit it is denominated in (`seconds`, `units`, …) — this
+ * bare count is ambiguous across providers. Still populated alongside
+ * `billed` for backward compatibility; will be removed in a future release.
*/
unitsBilled?: number
/** Provider-specific usage details not covered by standard fields */
diff --git a/packages/ai-fal/src/utils/billing.ts b/packages/ai-fal/src/utils/billing.ts
index 8df762bafc..4b5335d01f 100644
--- a/packages/ai-fal/src/utils/billing.ts
+++ b/packages/ai-fal/src/utils/billing.ts
@@ -74,9 +74,10 @@ export function takeBillableUnits(
/**
* Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
* no tokens, so the token fields are zero and the real billing signal rides on
- * `unitsBilled` — mirroring how the duration-billed transcription adapters
- * surface `durationSeconds`. Returns `undefined` when no units were captured so
- * callers can omit `usage` entirely.
+ * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined
+ * (its pricing page maps each endpoint to a unit price), so the count is opaque
+ * by design. Returns `undefined` when no units were captured so callers can
+ * omit `usage` entirely.
*/
export function buildFalUsage(
unitsBilled: number | undefined,
@@ -86,6 +87,7 @@ export function buildFalUsage(
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: unitsBilled, unit: 'units' },
unitsBilled,
}
}
diff --git a/packages/ai-fal/tests/audio-adapter.test.ts b/packages/ai-fal/tests/audio-adapter.test.ts
index 02e6d8ca79..b19a770d1e 100644
--- a/packages/ai-fal/tests/audio-adapter.test.ts
+++ b/packages/ai-fal/tests/audio-adapter.test.ts
@@ -366,6 +366,7 @@ describe('Fal Audio Adapter', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 2.5, unit: 'units' },
unitsBilled: 2.5,
})
})
diff --git a/packages/ai-fal/tests/billing.test.ts b/packages/ai-fal/tests/billing.test.ts
index d35c834dde..7b922393a7 100644
--- a/packages/ai-fal/tests/billing.test.ts
+++ b/packages/ai-fal/tests/billing.test.ts
@@ -85,6 +85,7 @@ describe('buildFalUsage', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 4, unit: 'units' },
unitsBilled: 4,
})
})
@@ -94,6 +95,7 @@ describe('buildFalUsage', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 0, unit: 'units' },
unitsBilled: 0,
})
})
diff --git a/packages/ai-fal/tests/image-adapter.test.ts b/packages/ai-fal/tests/image-adapter.test.ts
index dcbe1030da..d774685adc 100644
--- a/packages/ai-fal/tests/image-adapter.test.ts
+++ b/packages/ai-fal/tests/image-adapter.test.ts
@@ -379,6 +379,7 @@ describe('Fal Image Adapter', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 4, unit: 'units' },
unitsBilled: 4,
})
})
diff --git a/packages/ai-fal/tests/speech-adapter.test.ts b/packages/ai-fal/tests/speech-adapter.test.ts
index 2edafe4f90..090c7e95dd 100644
--- a/packages/ai-fal/tests/speech-adapter.test.ts
+++ b/packages/ai-fal/tests/speech-adapter.test.ts
@@ -338,6 +338,7 @@ describe('Fal Speech Adapter', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 3, unit: 'units' },
unitsBilled: 3,
})
})
diff --git a/packages/ai-fal/tests/transcription-adapter.test.ts b/packages/ai-fal/tests/transcription-adapter.test.ts
index 7b969b2aff..3e72b6485c 100644
--- a/packages/ai-fal/tests/transcription-adapter.test.ts
+++ b/packages/ai-fal/tests/transcription-adapter.test.ts
@@ -321,6 +321,7 @@ describe('Fal Transcription Adapter', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 1.5, unit: 'units' },
unitsBilled: 1.5,
})
})
diff --git a/packages/ai-fal/tests/video-adapter.test.ts b/packages/ai-fal/tests/video-adapter.test.ts
index b3778406f5..79e4969283 100644
--- a/packages/ai-fal/tests/video-adapter.test.ts
+++ b/packages/ai-fal/tests/video-adapter.test.ts
@@ -404,6 +404,7 @@ describe('Fal Video Adapter', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 12, unit: 'units' },
unitsBilled: 12,
})
})
diff --git a/packages/ai-grok/src/adapters/transcription.ts b/packages/ai-grok/src/adapters/transcription.ts
index cefdd17a77..e3048192c2 100644
--- a/packages/ai-grok/src/adapters/transcription.ts
+++ b/packages/ai-grok/src/adapters/transcription.ts
@@ -137,7 +137,7 @@ export class GrokTranscriptionAdapter<
const resolvedLanguage = data.language ?? language
// xAI's /v1/stt response carries no token counts — STT is duration-billed —
- // so surface the audio duration as `durationSeconds`, mirroring the
+ // so surface the audio duration as the billed quantity, mirroring the
// whisper-1 path in the OpenAI transcription adapter.
const usage: TokenUsage | undefined =
data.duration !== undefined && data.duration > 0
@@ -145,6 +145,7 @@ export class GrokTranscriptionAdapter<
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: data.duration, unit: 'seconds' },
durationSeconds: data.duration,
}
: undefined
diff --git a/packages/ai-grok/src/adapters/video.ts b/packages/ai-grok/src/adapters/video.ts
index 21807360e3..c7ce61ce5e 100644
--- a/packages/ai-grok/src/adapters/video.ts
+++ b/packages/ai-grok/src/adapters/video.ts
@@ -81,7 +81,10 @@ function buildGrokVideoUsage(
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
- ...(seconds !== undefined && { unitsBilled: seconds }),
+ ...(seconds !== undefined && {
+ billed: { quantity: seconds, unit: 'seconds' },
+ unitsBilled: seconds,
+ }),
...(ticks !== undefined && { cost: ticks / USD_TICKS_PER_DOLLAR }),
}
}
@@ -109,7 +112,7 @@ function buildGrokVideoUsage(
* - Aspect-ratio sizing via the "aspectRatio_resolution" size template
* (e.g. '16:9_720p'), consistent with the grok-imagine image models
* - Image-to-video via an `image` prompt part (starting frame URL or data URI)
- * - Usage reporting: billed seconds (`unitsBilled`) and exact cost
+ * - Usage reporting: billed seconds (`usage.billed`) and exact cost
*/
export class GrokVideoAdapter<
TModel extends GrokVideoModel,
diff --git a/packages/ai-grok/tests/audio-adapters.test.ts b/packages/ai-grok/tests/audio-adapters.test.ts
index e0255f610c..42602af672 100644
--- a/packages/ai-grok/tests/audio-adapters.test.ts
+++ b/packages/ai-grok/tests/audio-adapters.test.ts
@@ -300,6 +300,15 @@ describe('GrokTranscriptionAdapter', () => {
expect(result.text).toBe('hello world')
expect(result.language).toBe('en')
expect(result.duration).toBe(1.23)
+ // STT is duration-billed: the audio duration doubles as the billed
+ // quantity, self-described in seconds.
+ expect(result.usage).toEqual({
+ promptTokens: 0,
+ completionTokens: 0,
+ totalTokens: 0,
+ billed: { quantity: 1.23, unit: 'seconds' },
+ durationSeconds: 1.23,
+ })
// Grok returns `confidence` per word when the model provides one; we
// surface it under `GrokTranscriptionWord` so callers that know they're
// using Grok can narrow the result via `as Array`.
diff --git a/packages/ai-grok/tests/video-adapter.test.ts b/packages/ai-grok/tests/video-adapter.test.ts
index a6239adbc9..ae14e45a34 100644
--- a/packages/ai-grok/tests/video-adapter.test.ts
+++ b/packages/ai-grok/tests/video-adapter.test.ts
@@ -535,6 +535,7 @@ describe('Grok Video Adapter', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 5, unit: 'seconds' },
unitsBilled: 5,
cost: 0.25,
},
diff --git a/packages/ai-openai/src/adapters/transcription.ts b/packages/ai-openai/src/adapters/transcription.ts
index b5fcd23e9a..be3b9f42c8 100644
--- a/packages/ai-openai/src/adapters/transcription.ts
+++ b/packages/ai-openai/src/adapters/transcription.ts
@@ -52,6 +52,22 @@ function mapDiarizedSegmentId(id: string, index: number): number {
* Build TokenUsage from transcription response.
* Whisper-1 uses duration-based billing, GPT-4o models use token-based billing.
*/
+/**
+ * Duration-billed usage: zeroed token fields with the billed seconds carried
+ * on `billed` and, for backward compatibility, the deprecated
+ * `durationSeconds`. Shared by the gpt-4o duration branch and the whisper-1
+ * path so the two fields can't drift apart.
+ */
+function durationUsage(seconds: number): TokenUsage {
+ return {
+ promptTokens: 0,
+ completionTokens: 0,
+ totalTokens: 0,
+ billed: { quantity: seconds, unit: 'seconds' },
+ durationSeconds: seconds,
+ }
+}
+
function buildTranscriptionUsage(
model: string,
duration?: number,
@@ -72,12 +88,7 @@ function buildTranscriptionUsage(
// gpt-4o-transcribe-diarize responses may report duration-based usage;
// surface it rather than discarding billing data the API returned.
if (usage.type === 'duration') {
- return {
- promptTokens: 0,
- completionTokens: 0,
- totalTokens: 0,
- durationSeconds: usage.seconds,
- }
+ return durationUsage(usage.seconds)
}
const result: TokenUsage = {
@@ -111,12 +122,7 @@ function buildTranscriptionUsage(
// Whisper-1 uses duration-based billing
if (duration !== undefined && duration > 0) {
- return {
- promptTokens: 0,
- completionTokens: 0,
- totalTokens: 0,
- durationSeconds: duration,
- }
+ return durationUsage(duration)
}
return undefined
diff --git a/packages/ai-openai/tests/transcription-adapter.test.ts b/packages/ai-openai/tests/transcription-adapter.test.ts
index df022a196f..f04d7476ac 100644
--- a/packages/ai-openai/tests/transcription-adapter.test.ts
+++ b/packages/ai-openai/tests/transcription-adapter.test.ts
@@ -629,6 +629,7 @@ describe('OpenAI transcription adapter', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 2.5, unit: 'seconds' },
durationSeconds: 2.5,
})
})
diff --git a/packages/ai-openai/tests/transcription-usage.test.ts b/packages/ai-openai/tests/transcription-usage.test.ts
index 48e6917960..e084a79d15 100644
--- a/packages/ai-openai/tests/transcription-usage.test.ts
+++ b/packages/ai-openai/tests/transcription-usage.test.ts
@@ -97,6 +97,7 @@ describe('OpenAI transcription usage', () => {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
+ billed: { quantity: 12.5, unit: 'seconds' },
durationSeconds: 12.5,
})
})
diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md
index fdf185a615..71ad75efd4 100644
--- a/packages/ai/skills/ai-core/media-generation/SKILL.md
+++ b/packages/ai/skills/ai-core/media-generation/SKILL.md
@@ -547,7 +547,7 @@ durations 4/8/12s, single `input_reference` image prompt part), `grokVideo(...)`
(`grok-imagine-video` does text-to-video + image-to-video; `grok-imagine-video-1.5` is
image-to-video only — needs an `image` prompt part as the starting frame, text-only throws;
aspect-ratio size template like `'16:9_720p'`, integer durations 1-15s, reports
-`usage.unitsBilled` seconds and exact `usage.cost`), `byteplusVideo(...)` (Seedance —
+`usage.billed` seconds ({ quantity, unit: 'seconds' }) and exact `usage.cost`), `byteplusVideo(...)` (Seedance —
aspect-ratio size template like `'16:9_720p'`, durations 4-15s on the 2.0 family,
4-12s on 1.5-pro, 2-12s on the 1.0-pro models; reads `ARK_API_KEY`),
`openRouterVideo(...)` (OpenRouter's dedicated `POST /api/v1/videos` gateway),
@@ -606,10 +606,11 @@ const { generate, result, jobId, videoStatus, isLoading } = useGenerateVideo({
fal bills media generation by usage-based units, not tokens. Every fal media
adapter (`falImage`, `falAudio`, `falSpeech`, `falTranscription`, `falVideo`)
-surfaces the real billed quantity on the result as `usage.unitsBilled`, read
-from fal's `x-fal-billable-units` response header — no `fetch` interceptor
-needed. It rides on the canonical `TokenUsage` shape (token fields are `0` for
-media), mirroring how duration-billed transcription surfaces `durationSeconds`.
+surfaces the real billed quantity on the result as `usage.billed`
+({ quantity, unit: 'units' }), read from fal's `x-fal-billable-units` response
+header — no `fetch` interceptor needed. It rides on the canonical `TokenUsage`
+shape (token fields are `0` for media), mirroring how duration-billed
+transcription reports { quantity, unit: 'seconds' }.
```typescript
import { generateImage } from '@tanstack/ai'
@@ -620,10 +621,10 @@ const result = await generateImage({
prompt: 'a serene mountain lake',
})
-// usage.unitsBilled is the priced quantity. Multiply by the endpoint unit
+// usage.billed.quantity is the priced quantity. Multiply by the endpoint unit
// price (GET https://api.fal.ai/v1/models/pricing?endpoint_id=…) for exact cost.
-if (result.usage?.unitsBilled != null) {
- const cost = result.usage.unitsBilled * unitPrice
+if (result.usage?.billed) {
+ const cost = result.usage.billed.quantity * unitPrice
}
```
diff --git a/packages/ai/src/middlewares/usage-attributes.ts b/packages/ai/src/middlewares/usage-attributes.ts
index ef17f43ee4..1e669116b0 100644
--- a/packages/ai/src/middlewares/usage-attributes.ts
+++ b/packages/ai/src/middlewares/usage-attributes.ts
@@ -12,8 +12,8 @@ import type { TokenUsage } from '../types'
* `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions
* consumed by backends like PostHog (which otherwise re-derive cost from their
* own price tables, losing cache discounts and gateway markup). Fields with no
- * semconv or de-facto convention (`costDetails`, `durationSeconds`,
- * `unitsBilled`) are TanStack-namespaced.
+ * semconv or de-facto convention (`billed`, `costDetails`, and the deprecated
+ * `durationSeconds`/`unitsBilled`) are TanStack-namespaced.
*
* Shared by `otelMiddleware` across every activity (chat and the media
* activities) so usage lands identically whichever activity produced the span.
@@ -30,6 +30,16 @@ export function usageAttributes(
'gen_ai.usage.input_tokens': usage.promptTokens,
'gen_ai.usage.output_tokens': usage.completionTokens,
}
+ // The self-describing billed quantity: the unit rides along as a string
+ // attribute so backends can label/aggregate non-token usage without
+ // out-of-band knowledge of the provider.
+ if (usage.billed !== undefined) {
+ const quantity = firstNumber(usage.billed.quantity)
+ if (quantity !== undefined) {
+ attrs['tanstack.ai.usage.billed_quantity'] = quantity
+ attrs['tanstack.ai.usage.billed_unit'] = usage.billed.unit
+ }
+ }
const optional: Array<[key: string, value: unknown]> = [
['gen_ai.usage.total_tokens', usage.totalTokens],
['gen_ai.usage.cost', usage.cost],
diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts
index 49e7928a99..31b51e4a06 100644
--- a/packages/ai/src/types.ts
+++ b/packages/ai/src/types.ts
@@ -11,6 +11,8 @@ import type { ProviderTool } from './tools/provider-tool'
// package (which `@tanstack/ai` already depends on) so there is a single source
// of truth without a dependency cycle. They are re-exported below.
import type {
+ BilledUsage,
+ BillingUnit,
CompletionTokensDetails,
PromptTokensDetails,
ProviderUsageDetails,
@@ -1103,6 +1105,8 @@ export interface RunStartedEvent extends AGUIRunStartedEvent {
// Re-export the canonical usage types (defined in `@tanstack/ai-event-client`)
// so `@tanstack/ai` consumers keep importing them from here unchanged.
export type {
+ BilledUsage,
+ BillingUnit,
CompletionTokensDetails,
PromptTokensDetails,
ProviderUsageDetails,
@@ -2460,8 +2464,8 @@ export interface VideoUrlResult {
expiresAt?: Date
/**
* Usage information for the completed generation, when the adapter can report
- * it. For usage-based providers (e.g. fal) this carries `unitsBilled` — the
- * real billed quantity — so consumers can compute exact cost.
+ * it. For usage-based providers (e.g. fal) this carries `billed` — the real
+ * billed quantity paired with its unit — so consumers can compute exact cost.
*/
usage?: TokenUsage
/** Persisted artifact references for generated assets, when available */
diff --git a/packages/ai/tests/middlewares/otel.test.ts b/packages/ai/tests/middlewares/otel.test.ts
index 5849c617cd..5ef7113de4 100644
--- a/packages/ai/tests/middlewares/otel.test.ts
+++ b/packages/ai/tests/middlewares/otel.test.ts
@@ -1542,4 +1542,37 @@ describe('usageAttributes', () => {
// No cost reported → key absent.
expect('gen_ai.usage.cost' in attrs).toBe(false)
})
+
+ it('emits the self-describing billed quantity with its unit', () => {
+ const usage: TokenUsage = {
+ promptTokens: 0,
+ completionTokens: 0,
+ totalTokens: 0,
+ billed: { quantity: 8, unit: 'seconds' },
+ }
+ const attrs = usageAttributes(usage)
+
+ expect(attrs['tanstack.ai.usage.billed_quantity']).toBe(8)
+ expect(attrs['tanstack.ai.usage.billed_unit']).toBe('seconds')
+ })
+
+ it('omits both billed attributes when billed is absent or non-numeric', () => {
+ const attrs = usageAttributes({
+ promptTokens: 1,
+ completionTokens: 2,
+ totalTokens: 3,
+ })
+ expect('tanstack.ai.usage.billed_quantity' in attrs).toBe(false)
+ expect('tanstack.ai.usage.billed_unit' in attrs).toBe(false)
+
+ // A NaN quantity (bad provider data) must not emit a dangling unit.
+ const bad = usageAttributes({
+ promptTokens: 1,
+ completionTokens: 2,
+ totalTokens: 3,
+ billed: { quantity: Number.NaN, unit: 'units' },
+ })
+ expect('tanstack.ai.usage.billed_quantity' in bad).toBe(false)
+ expect('tanstack.ai.usage.billed_unit' in bad).toBe(false)
+ })
})
diff --git a/testing/e2e/global-setup.ts b/testing/e2e/global-setup.ts
index 0502e4418c..3bf98bd946 100644
--- a/testing/e2e/global-setup.ts
+++ b/testing/e2e/global-setup.ts
@@ -143,10 +143,14 @@ export default async function globalSetup() {
}
function registerMediaFixtures(mock: LLMock) {
- // Transcription: onTranscription sets match.endpoint = "transcription"
+ // Transcription: onTranscription sets match.endpoint = "transcription".
+ // `duration` is only served on verbose_json responses (whisper-1's default
+ // mode) — the otel middleware spec asserts it surfaces as the
+ // self-describing `billed` usage on the transcription span.
mock.onTranscription({
transcription: {
text: 'I would like to buy a Fender Stratocaster please',
+ duration: 2.4,
},
})
diff --git a/testing/e2e/src/lib/otel-local-tracer.ts b/testing/e2e/src/lib/otel-local-tracer.ts
new file mode 100644
index 0000000000..1917fe9c65
--- /dev/null
+++ b/testing/e2e/src/lib/otel-local-tracer.ts
@@ -0,0 +1,102 @@
+import type {
+ AttributeValue,
+ Context,
+ Span,
+ SpanContext,
+ SpanStatus,
+ Tracer,
+} from '@opentelemetry/api'
+
+export interface LocalCapturedSpan {
+ name: string
+ kind?: number
+ attributes: Record
+ status: SpanStatus
+ ended: boolean
+}
+
+/**
+ * Single-request in-memory tracer shared by the `api.otel-*` routes. Unlike
+ * the per-testId capture in `otel-capture.ts` (used by
+ * `api.middleware-test.ts`), everything in those routes happens inside one
+ * POST, so spans collect into a local array returned directly in the response
+ * body.
+ */
+export function createLocalCaptureTracer(): {
+ tracer: Tracer
+ spans: Array
+} {
+ const spans: Array = []
+ let spanSeq = 0
+ const tracer: Tracer = {
+ startSpan(name, options = {}, _ctx?: Context): Span {
+ const id = `span-${spanSeq++}`
+ const attributes: Record = {}
+ for (const [k, v] of Object.entries(options.attributes ?? {})) {
+ if (v !== undefined) attributes[k] = v
+ }
+ const captured: LocalCapturedSpan = {
+ name,
+ kind: options.kind,
+ attributes,
+ status: { code: 0 },
+ ended: false,
+ }
+ spans.push(captured)
+ const span: Span = {
+ spanContext(): SpanContext {
+ return { traceId: 'otel-local-trace', spanId: id, traceFlags: 1 }
+ },
+ setAttribute(key, value) {
+ captured.attributes[key] = value
+ return span
+ },
+ setAttributes(next) {
+ for (const [k, v] of Object.entries(next)) {
+ captured.attributes[k] = v as AttributeValue
+ }
+ return span
+ },
+ addEvent() {
+ return span
+ },
+ addLink() {
+ return span
+ },
+ addLinks() {
+ return span
+ },
+ setStatus(status) {
+ captured.status = status
+ return span
+ },
+ updateName(next) {
+ captured.name = next
+ return span
+ },
+ end() {
+ captured.ended = true
+ },
+ isRecording() {
+ return !captured.ended
+ },
+ recordException() {},
+ }
+ return span
+ },
+
+ // Minimal implementation — otelMiddleware never calls startActiveSpan.
+
+ startActiveSpan(...args: Array) {
+ const fn = args[args.length - 1] as (span: Span) => unknown
+ const name = args[0] as string
+ const span = tracer.startSpan(name, {})
+ try {
+ return fn(span)
+ } finally {
+ span.end()
+ }
+ },
+ }
+ return { tracer, spans }
+}
diff --git a/testing/e2e/src/lib/request-body.ts b/testing/e2e/src/lib/request-body.ts
new file mode 100644
index 0000000000..dc15ed4e2c
--- /dev/null
+++ b/testing/e2e/src/lib/request-body.ts
@@ -0,0 +1,21 @@
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+/**
+ * Extract the payload record from an `api.otel-*` route's POST body,
+ * unwrapping the `forwardedProps` / `data` envelopes the test harness may
+ * nest it in. Throws on any non-object shape.
+ */
+export function recordFromBody(body: unknown): Record {
+ 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
+}
diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts
index c6d9853490..4965276788 100644
--- a/testing/e2e/src/routeTree.gen.ts
+++ b/testing/e2e/src/routeTree.gen.ts
@@ -37,6 +37,7 @@ import { Route as ApiSandboxToolHistoryRouteImport } from './routes/api.sandbox-
import { Route as ApiSandboxDurabilityRouteImport } from './routes/api.sandbox-durability'
import { Route as ApiPersistenceDurabilityRouteImport } from './routes/api.persistence-durability'
import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage'
+import { Route as ApiOtelTranscriptionRouteImport } from './routes/api.otel-transcription'
import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media'
import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire'
import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost'
@@ -221,6 +222,11 @@ const ApiOtelUsageRoute = ApiOtelUsageRouteImport.update({
path: '/api/otel-usage',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiOtelTranscriptionRoute = ApiOtelTranscriptionRouteImport.update({
+ id: '/api/otel-transcription',
+ path: '/api/otel-transcription',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiOtelMediaRoute = ApiOtelMediaRouteImport.update({
id: '/api/otel-media',
path: '/api/otel-media',
@@ -475,6 +481,7 @@ export interface FileRoutesByFullPath {
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/otel-media': typeof ApiOtelMediaRoute
+ '/api/otel-transcription': typeof ApiOtelTranscriptionRoute
'/api/otel-usage': typeof ApiOtelUsageRoute
'/api/persistence-durability': typeof ApiPersistenceDurabilityRoute
'/api/sandbox-durability': typeof ApiSandboxDurabilityRoute
@@ -544,6 +551,7 @@ export interface FileRoutesByTo {
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/otel-media': typeof ApiOtelMediaRoute
+ '/api/otel-transcription': typeof ApiOtelTranscriptionRoute
'/api/otel-usage': typeof ApiOtelUsageRoute
'/api/persistence-durability': typeof ApiPersistenceDurabilityRoute
'/api/sandbox-durability': typeof ApiSandboxDurabilityRoute
@@ -614,6 +622,7 @@ export interface FileRoutesById {
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/otel-media': typeof ApiOtelMediaRoute
+ '/api/otel-transcription': typeof ApiOtelTranscriptionRoute
'/api/otel-usage': typeof ApiOtelUsageRoute
'/api/persistence-durability': typeof ApiPersistenceDurabilityRoute
'/api/sandbox-durability': typeof ApiSandboxDurabilityRoute
@@ -685,6 +694,7 @@ export interface FileRouteTypes {
| '/api/openrouter-cost'
| '/api/openrouter-web-tools-wire'
| '/api/otel-media'
+ | '/api/otel-transcription'
| '/api/otel-usage'
| '/api/persistence-durability'
| '/api/sandbox-durability'
@@ -754,6 +764,7 @@ export interface FileRouteTypes {
| '/api/openrouter-cost'
| '/api/openrouter-web-tools-wire'
| '/api/otel-media'
+ | '/api/otel-transcription'
| '/api/otel-usage'
| '/api/persistence-durability'
| '/api/sandbox-durability'
@@ -823,6 +834,7 @@ export interface FileRouteTypes {
| '/api/openrouter-cost'
| '/api/openrouter-web-tools-wire'
| '/api/otel-media'
+ | '/api/otel-transcription'
| '/api/otel-usage'
| '/api/persistence-durability'
| '/api/sandbox-durability'
@@ -893,6 +905,7 @@ export interface RootRouteChildren {
ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute
ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute
ApiOtelMediaRoute: typeof ApiOtelMediaRoute
+ ApiOtelTranscriptionRoute: typeof ApiOtelTranscriptionRoute
ApiOtelUsageRoute: typeof ApiOtelUsageRoute
ApiPersistenceDurabilityRoute: typeof ApiPersistenceDurabilityRoute
ApiSandboxDurabilityRoute: typeof ApiSandboxDurabilityRoute
@@ -1104,6 +1117,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiOtelUsageRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/otel-transcription': {
+ id: '/api/otel-transcription'
+ path: '/api/otel-transcription'
+ fullPath: '/api/otel-transcription'
+ preLoaderRoute: typeof ApiOtelTranscriptionRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/otel-media': {
id: '/api/otel-media'
path: '/api/otel-media'
@@ -1490,6 +1510,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiOpenrouterCostRoute: ApiOpenrouterCostRoute,
ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute,
ApiOtelMediaRoute: ApiOtelMediaRoute,
+ ApiOtelTranscriptionRoute: ApiOtelTranscriptionRoute,
ApiOtelUsageRoute: ApiOtelUsageRoute,
ApiPersistenceDurabilityRoute: ApiPersistenceDurabilityRoute,
ApiSandboxDurabilityRoute: ApiSandboxDurabilityRoute,
diff --git a/testing/e2e/src/routes/api.otel-media.ts b/testing/e2e/src/routes/api.otel-media.ts
index a749a973ca..049ff4cca8 100644
--- a/testing/e2e/src/routes/api.otel-media.ts
+++ b/testing/e2e/src/routes/api.otel-media.ts
@@ -2,122 +2,9 @@ import { createFileRoute } from '@tanstack/react-router'
import { generateImage } from '@tanstack/ai'
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import type { Provider } from '@/lib/types'
-import type {
- AttributeValue,
- Context,
- Span,
- SpanContext,
- SpanStatus,
- Tracer,
-} from '@opentelemetry/api'
import { createImageAdapter } from '@/lib/media-providers'
-
-interface CapturedSpan {
- name: string
- kind?: number
- attributes: Record
- status: SpanStatus
- ended: boolean
-}
-
-/**
- * Single-request in-memory tracer (mirrors `api.otel-usage.ts`). Everything
- * happens inside one POST, so spans collect into a local array returned in the
- * response body.
- */
-function createLocalCaptureTracer(): {
- tracer: Tracer
- spans: Array
-} {
- const spans: Array = []
- let spanSeq = 0
- const tracer: Tracer = {
- startSpan(name, options = {}, _ctx?: Context): Span {
- const id = `span-${spanSeq++}`
- const attributes: Record = {}
- for (const [k, v] of Object.entries(options.attributes ?? {})) {
- if (v !== undefined) attributes[k] = v
- }
- const captured: CapturedSpan = {
- name,
- kind: options.kind,
- attributes,
- status: { code: 0 },
- ended: false,
- }
- spans.push(captured)
- const span: Span = {
- spanContext(): SpanContext {
- return { traceId: 'otel-media-trace', spanId: id, traceFlags: 1 }
- },
- setAttribute(key, value) {
- captured.attributes[key] = value
- return span
- },
- setAttributes(next) {
- for (const [k, v] of Object.entries(next)) {
- captured.attributes[k] = v as AttributeValue
- }
- return span
- },
- addEvent() {
- return span
- },
- addLink() {
- return span
- },
- addLinks() {
- return span
- },
- setStatus(status) {
- captured.status = status
- return span
- },
- updateName(next) {
- captured.name = next
- return span
- },
- end() {
- captured.ended = true
- },
- isRecording() {
- return !captured.ended
- },
- recordException() {},
- }
- return span
- },
-
- startActiveSpan(...args: Array) {
- const fn = args[args.length - 1] as (span: Span) => unknown
- const name = args[0] as string
- const span = tracer.startSpan(name, {})
- try {
- return fn(span)
- } finally {
- span.end()
- }
- },
- }
- return { tracer, spans }
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null
-}
-
-function recordFromBody(body: unknown): Record {
- 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 { createLocalCaptureTracer } from '@/lib/otel-local-tracer'
+import { recordFromBody } from '@/lib/request-body'
/**
* Drives `generateImage` with `otelMiddleware` against the same aimock mount
diff --git a/testing/e2e/src/routes/api.otel-transcription.ts b/testing/e2e/src/routes/api.otel-transcription.ts
new file mode 100644
index 0000000000..6350261077
--- /dev/null
+++ b/testing/e2e/src/routes/api.otel-transcription.ts
@@ -0,0 +1,64 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { generateTranscription } from '@tanstack/ai'
+import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
+import type { Provider } from '@/lib/types'
+import { createTranscriptionAdapter } from '@/lib/media-providers'
+import { createLocalCaptureTracer } from '@/lib/otel-local-tracer'
+import { recordFromBody } from '@/lib/request-body'
+
+/**
+ * Drives `generateTranscription` with `otelMiddleware` against the whisper
+ * aimock fixture (which reports an audio `duration`), and returns the captured
+ * spans. End-to-end proof that a duration-billed activity surfaces the
+ * self-describing billed quantity on its span:
+ * `tanstack.ai.usage.billed_quantity` + `tanstack.ai.usage.billed_unit`.
+ */
+export const Route = createFileRoute('/api/otel-transcription')({
+ server: {
+ handlers: {
+ POST: async ({ request }) => {
+ await import('@/lib/llmock-server').then((m) => m.ensureLLMock())
+
+ try {
+ const body: unknown = await request.json()
+ const data = recordFromBody(body)
+ const audio = data.audio
+ const provider = data.provider
+ if (typeof audio !== 'string' || typeof provider !== 'string') {
+ throw new Error('Missing required fields: audio/provider')
+ }
+
+ const testId =
+ typeof data.testId === 'string' ? data.testId : undefined
+ const aimockPort =
+ typeof data.aimockPort === 'number' ? data.aimockPort : undefined
+ const adapter = createTranscriptionAdapter(
+ provider as Provider,
+ aimockPort,
+ testId,
+ )
+ const { tracer, spans } = createLocalCaptureTracer()
+
+ await generateTranscription({
+ adapter,
+ audio,
+ middleware: [otelMiddleware({ tracer })],
+ })
+
+ return new Response(JSON.stringify({ ok: true, spans }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ } catch (error) {
+ return new Response(
+ JSON.stringify({
+ ok: false,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ )
+ }
+ },
+ },
+ },
+})
diff --git a/testing/e2e/src/routes/api.otel-usage.ts b/testing/e2e/src/routes/api.otel-usage.ts
index 66eda9b729..4937988974 100644
--- a/testing/e2e/src/routes/api.otel-usage.ts
+++ b/testing/e2e/src/routes/api.otel-usage.ts
@@ -4,14 +4,8 @@ import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { createOpenaiChatCompletions } from '@tanstack/ai-openai'
import { createOpenRouterText } from '@tanstack/ai-openrouter'
import { z } from 'zod'
+import { createLocalCaptureTracer } from '@/lib/otel-local-tracer'
import { createTextAdapter } from '@/lib/providers'
-import type {
- AttributeValue,
- Context,
- Span,
- SpanContext,
- Tracer,
-} from '@opentelemetry/api'
const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010'
const DUMMY_KEY = 'sk-e2e-test-dummy-key'
@@ -21,94 +15,6 @@ const weatherTool = toolDefinition({
inputSchema: z.object({ city: z.string() }),
}).server(async ({ city }) => ({ city, temperature: 72, condition: 'sunny' }))
-interface CapturedSpan {
- name: string
- kind?: number
- attributes: Record
- ended: boolean
-}
-
-/**
- * Single-request in-memory tracer. Unlike the per-testId capture in
- * `api.middleware-test.ts`, everything here happens inside one POST, so spans
- * collect into a local array returned directly in the response body.
- */
-function createLocalCaptureTracer(): {
- tracer: Tracer
- spans: Array
-} {
- const spans: Array = []
- let spanSeq = 0
- const tracer: Tracer = {
- startSpan(name, options = {}, _ctx?: Context): Span {
- const id = `span-${spanSeq++}`
- const attributes: Record = {}
- for (const [k, v] of Object.entries(options.attributes ?? {})) {
- if (v !== undefined) attributes[k] = v
- }
- const captured: CapturedSpan = {
- name,
- kind: options.kind,
- attributes,
- ended: false,
- }
- spans.push(captured)
- const span: Span = {
- spanContext(): SpanContext {
- return { traceId: 'otel-usage-trace', spanId: id, traceFlags: 1 }
- },
- setAttribute(key, value) {
- captured.attributes[key] = value
- return span
- },
- setAttributes(next) {
- for (const [k, v] of Object.entries(next)) {
- captured.attributes[k] = v as AttributeValue
- }
- return span
- },
- addEvent() {
- return span
- },
- addLink() {
- return span
- },
- addLinks() {
- return span
- },
- setStatus() {
- return span
- },
- updateName(next) {
- captured.name = next
- return span
- },
- end() {
- captured.ended = true
- },
- isRecording() {
- return !captured.ended
- },
- recordException() {},
- }
- return span
- },
- // Minimal implementation — otelMiddleware never calls startActiveSpan.
-
- startActiveSpan(...args: Array) {
- const fn = args[args.length - 1] as (span: Span) => unknown
- const name = args[0] as string
- const span = tracer.startSpan(name, {})
- try {
- return fn(span)
- } finally {
- span.end()
- }
- },
- }
- return { tracer, spans }
-}
-
/**
* Drives a chat adapter with `otelMiddleware` against the existing
* hand-crafted aimock mounts that report rich usage, and returns the captured
diff --git a/testing/e2e/tests/middleware.spec.ts b/testing/e2e/tests/middleware.spec.ts
index 184300136e..c14c8269bb 100644
--- a/testing/e2e/tests/middleware.spec.ts
+++ b/testing/e2e/tests/middleware.spec.ts
@@ -432,6 +432,43 @@ test.describe('Middleware Lifecycle', () => {
})
})
+ test('otel middleware emits the self-describing billed quantity for a duration-billed activity', async ({
+ request,
+ testId,
+ aimockPort,
+ }) => {
+ // `/api/otel-transcription` drives whisper-1 (duration-billed) against the
+ // transcription aimock fixture, whose response reports `duration: 2.4`.
+ // The adapter surfaces that as `usage.billed = { quantity, unit }`, and the
+ // middleware must emit it as the paired billed_quantity/billed_unit
+ // attributes — the machine-readable unit that #816 adds.
+ const res = await request.post('/api/otel-transcription', {
+ data: {
+ audio: 'data:audio/mpeg;base64,SGVsbG8=',
+ provider: 'openai',
+ testId,
+ aimockPort,
+ },
+ })
+ expect(res.ok()).toBe(true)
+ const { ok, error, spans } = await res.json()
+ expect(error ?? null).toBeNull()
+ expect(ok).toBe(true)
+
+ const mediaSpans = spans.filter(
+ (s: any) => s.attributes['gen_ai.operation.name'] === 'transcription',
+ )
+ expect(mediaSpans).toHaveLength(1)
+ expect(mediaSpans[0].ended).toBe(true)
+ expect(mediaSpans[0].attributes).toMatchObject({
+ 'gen_ai.request.model': 'whisper-1',
+ 'tanstack.ai.usage.billed_quantity': 2.4,
+ 'tanstack.ai.usage.billed_unit': 'seconds',
+ // Deprecated bare count still emitted for backward compatibility.
+ 'tanstack.ai.usage.duration_seconds': 2.4,
+ })
+ })
+
test('no middleware passes content through unchanged', async ({
page,
testId,