From d2f66bdaed7f9425d657aa31972f71ad808293fd Mon Sep 17 00:00:00 2001 From: Season Date: Sat, 4 Jul 2026 19:56:46 +0800 Subject: [PATCH 1/3] feat: add self-describing billed usage to TokenUsage 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 #816 --- .changeset/billed-usage-unit.md | 9 ++ docs/adapters/grok.md | 2 +- docs/advanced/otel.md | 11 +- docs/config.json | 10 +- docs/media/audio-generation.md | 7 +- docs/media/image-generation.md | 15 +-- docs/media/video-generation.md | 21 ++-- .../src/components/ImageGenerator.tsx | 10 +- .../src/components/VideoGenerator.tsx | 34 ++++-- .../src/lib/server-functions.ts | 4 +- packages/ai-event-client/src/index.ts | 59 ++++++++-- packages/ai-fal/src/utils/billing.ts | 8 +- packages/ai-fal/tests/audio-adapter.test.ts | 1 + packages/ai-fal/tests/billing.test.ts | 2 + packages/ai-fal/tests/image-adapter.test.ts | 1 + packages/ai-fal/tests/speech-adapter.test.ts | 1 + .../tests/transcription-adapter.test.ts | 1 + packages/ai-fal/tests/video-adapter.test.ts | 1 + .../ai-grok/src/adapters/transcription.ts | 3 +- packages/ai-grok/src/adapters/video.ts | 7 +- packages/ai-grok/tests/audio-adapters.test.ts | 9 ++ packages/ai-grok/tests/video-adapter.test.ts | 1 + .../ai-openai/src/adapters/transcription.ts | 2 + .../tests/transcription-adapter.test.ts | 1 + .../tests/transcription-usage.test.ts | 1 + .../skills/ai-core/media-generation/SKILL.md | 17 +-- .../ai/src/middlewares/usage-attributes.ts | 14 ++- packages/ai/src/types.ts | 8 +- packages/ai/tests/middlewares/otel.test.ts | 33 ++++++ testing/e2e/global-setup.ts | 6 +- testing/e2e/src/lib/otel-local-tracer.ts | 102 ++++++++++++++++++ testing/e2e/src/routeTree.gen.ts | 21 ++++ testing/e2e/src/routes/api.otel-media.ts | 99 +---------------- .../e2e/src/routes/api.otel-transcription.ts | 80 ++++++++++++++ testing/e2e/src/routes/api.otel-usage.ts | 96 +---------------- testing/e2e/tests/middleware.spec.ts | 37 +++++++ 36 files changed, 470 insertions(+), 264 deletions(-) create mode 100644 .changeset/billed-usage-unit.md create mode 100644 testing/e2e/src/lib/otel-local-tracer.ts create mode 100644 testing/e2e/src/routes/api.otel-transcription.ts diff --git a/.changeset/billed-usage-unit.md b/.changeset/billed-usage-unit.md new file mode 100644 index 0000000000..649c3fa1aa --- /dev/null +++ b/.changeset/billed-usage-unit.md @@ -0,0 +1,9 @@ +--- +'@tanstack/ai-event-client': minor +'@tanstack/ai': minor +'@tanstack/ai-fal': minor +'@tanstack/ai-grok': minor +'@tanstack/ai-openai': 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' }`, and the OpenAI/Grok duration-billed transcription paths `{ quantity, unit: 'seconds' }`. `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 3bdc1513b6..6bd571fbe5 100644 --- a/docs/advanced/otel.md +++ b/docs/advanced/otel.md @@ -77,7 +77,10 @@ Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the sam | 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 | @@ -90,7 +93,9 @@ Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the sam | 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 @@ -232,7 +237,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 e50a13e3f8..c6bf459177 100644 --- a/docs/config.json +++ b/docs/config.json @@ -426,19 +426,19 @@ "label": "Audio Generation", "to": "media/audio-generation", "addedAt": "2026-04-23", - "updatedAt": "2026-07-28" + "updatedAt": "2026-08-02" }, { "label": "Image Generation", "to": "media/image-generation", "addedAt": "2026-04-15", - "updatedAt": "2026-07-30" + "updatedAt": "2026-08-02" }, { "label": "Video Generation", "to": "media/video-generation", "addedAt": "2026-04-15", - "updatedAt": "2026-07-30" + "updatedAt": "2026-08-02" }, { "label": "Generation Hooks", @@ -473,7 +473,7 @@ "label": "OpenTelemetry", "to": "advanced/otel", "addedAt": "2026-05-08", - "updatedAt": "2026-07-31" + "updatedAt": "2026-08-02" } ] }, @@ -752,7 +752,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 4b323d1a45..06deae52ee 100644 --- a/docs/media/audio-generation.md +++ b/docs/media/audio-generation.md @@ -130,9 +130,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 fbe8f212e4..acae4022f8 100644 --- a/docs/media/image-generation.md +++ b/docs/media/image-generation.md @@ -422,7 +422,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 } @@ -434,9 +434,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. @@ -450,9 +450,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 750cbdd9b2..1ea42767ed 100644 --- a/docs/media/video-generation.md +++ b/docs/media/video-generation.md @@ -720,7 +720,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. ## Response Types @@ -755,19 +755,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 09e2eb7d49..adf61d5427 100644 --- a/examples/ts-react-media/src/components/ImageGenerator.tsx +++ b/examples/ts-react-media/src/components/ImageGenerator.tsx @@ -304,12 +304,12 @@ export default function ImageGenerator({ className="w-full h-auto" /> - {modelResult.result.usage?.unitsBilled != null && ( + {modelResult.result.usage?.billed && (

- Billed {modelResult.result.usage.unitsBilled} fal unit - {modelResult.result.usage.unitsBilled === 1 - ? '' - : 's'}{' '} + Billed {modelResult.result.usage.billed.quantity}{' '} + {modelResult.result.usage.billed.unit === 'units' + ? `fal unit${modelResult.result.usage.billed.quantity === 1 ? '' : 's'}` + : modelResult.result.usage.billed.unit}{' '} — multiply by the endpoint unit price for USD cost

)} diff --git a/examples/ts-react-media/src/components/VideoGenerator.tsx b/examples/ts-react-media/src/components/VideoGenerator.tsx index e039f8c2ce..506b58872e 100644 --- a/examples/ts-react-media/src/components/VideoGenerator.tsx +++ b/examples/ts-react-media/src/components/VideoGenerator.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Film, Loader2, Shuffle, Upload, Wand2, X } from 'lucide-react' +import type { BilledUsage } from '@tanstack/ai' import type { VideoMode } from '@/lib/models' import type { AttachedMedia } from '@/lib/media' import type { MediaPromptPart } from '@tanstack/ai/client' @@ -27,11 +28,27 @@ type JobState = status: 'completed' url: string jobId: string - unitsBilled?: number + billed?: BilledUsage cost?: number } | { status: 'error'; message: string } +/** + * 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}` + } +} + interface VideoGeneratorProps { initialImageUrl?: string | null } @@ -131,7 +148,7 @@ export default function VideoGenerator({ status: 'completed', url: url, jobId, - unitsBilled: urlResult.usage?.unitsBilled, + billed: urlResult.usage?.billed, cost: urlResult.usage?.cost, }, })) @@ -574,16 +591,17 @@ export default function VideoGenerator({ {state.cost != null ? (

Billed ${state.cost.toFixed(3)} - {state.unitsBilled != null - ? ` for ${state.unitsBilled} second${state.unitsBilled === 1 ? '' : 's'} of video` + {state.billed + ? ` for ${describeBilled(state.billed)}` : ''}

) : ( - state.unitsBilled != null && ( + state.billed && (

- Billed {state.unitsBilled} fal unit - {state.unitsBilled === 1 ? '' : 's'} — multiply by the - endpoint unit price for USD cost + Billed {describeBilled(state.billed)} + {state.billed.unit === 'units' + ? ' — multiply by the endpoint unit price for USD cost' + : ''}

) )} diff --git a/examples/ts-react-media/src/lib/server-functions.ts b/examples/ts-react-media/src/lib/server-functions.ts index 53aa3f10c8..2b18388ba3 100644 --- a/examples/ts-react-media/src/lib/server-functions.ts +++ b/examples/ts-react-media/src/lib/server-functions.ts @@ -299,8 +299,8 @@ export const createVideoJobFn = createServerFn({ method: 'POST' }) 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({ adapter: grokVideo('grok-imagine-video'), prompt: asTextPrompt(data.prompt), diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 7465168bef..ba1357ae76 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 b429f1000b..d2d2f22d5f 100644 --- a/packages/ai-fal/tests/image-adapter.test.ts +++ b/packages/ai-fal/tests/image-adapter.test.ts @@ -335,6 +335,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..dd9b0aef64 100644 --- a/packages/ai-openai/src/adapters/transcription.ts +++ b/packages/ai-openai/src/adapters/transcription.ts @@ -76,6 +76,7 @@ function buildTranscriptionUsage( promptTokens: 0, completionTokens: 0, totalTokens: 0, + billed: { quantity: usage.seconds, unit: 'seconds' }, durationSeconds: usage.seconds, } } @@ -115,6 +116,7 @@ function buildTranscriptionUsage( promptTokens: 0, completionTokens: 0, totalTokens: 0, + billed: { quantity: duration, unit: 'seconds' }, durationSeconds: duration, } } 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 f66b4812ec..dd235b74c8 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -517,7 +517,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`), and `falVideo(...)` (hosted models, see cost tracking below). +`usage.billed` seconds ({ quantity, unit: 'seconds' }) and exact `usage.cost`), and `falVideo(...)` (hosted models, see cost tracking below). Client hook with job tracking: @@ -539,10 +539,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' @@ -553,10 +554,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 af1a200598..95b3b707b0 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, @@ -1098,6 +1100,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, @@ -2361,8 +2365,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 a5d3afa85b..3ba6b1dbea 100644 --- a/packages/ai/tests/middlewares/otel.test.ts +++ b/packages/ai/tests/middlewares/otel.test.ts @@ -1336,4 +1336,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 27204a5cd3..3462f78252 100644 --- a/testing/e2e/global-setup.ts +++ b/testing/e2e/global-setup.ts @@ -110,10 +110,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/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 9ff1c0e51a..9496727aff 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -36,6 +36,7 @@ import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' 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' @@ -212,6 +213,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', @@ -448,6 +454,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 @@ -513,6 +520,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 @@ -579,6 +587,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 @@ -646,6 +655,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' @@ -711,6 +721,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' @@ -776,6 +787,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' @@ -842,6 +854,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 @@ -1045,6 +1058,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' @@ -1407,6 +1427,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..dd454f33b0 100644 --- a/testing/e2e/src/routes/api.otel-media.ts +++ b/testing/e2e/src/routes/api.otel-media.ts @@ -2,105 +2,8 @@ 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 } -} +import { createLocalCaptureTracer } from '@/lib/otel-local-tracer' function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null 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..c09dfd4605 --- /dev/null +++ b/testing/e2e/src/routes/api.otel-transcription.ts @@ -0,0 +1,80 @@ +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' + +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 +} + +/** + * 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 6567256ef7..f372387a73 100644 --- a/testing/e2e/src/routes/api.otel-usage.ts +++ b/testing/e2e/src/routes/api.otel-usage.ts @@ -3,105 +3,11 @@ import { chat, createChatOptions } from '@tanstack/ai' import { otelMiddleware } from '@tanstack/ai/middlewares/otel' import { createOpenaiChatCompletions } from '@tanstack/ai-openai' import { createOpenRouterText } from '@tanstack/ai-openrouter' -import type { - AttributeValue, - Context, - Span, - SpanContext, - Tracer, -} from '@opentelemetry/api' +import { createLocalCaptureTracer } from '@/lib/otel-local-tracer' const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' const DUMMY_KEY = 'sk-e2e-test-dummy-key' -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 d1f146a7f7..a7de940b31 100644 --- a/testing/e2e/tests/middleware.spec.ts +++ b/testing/e2e/tests/middleware.spec.ts @@ -335,6 +335,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, From ebeab868e216be19e115443bd73d3857f7ec65ba Mon Sep 17 00:00:00 2001 From: Season Date: Sat, 4 Jul 2026 20:09:41 +0800 Subject: [PATCH 2/3] refactor: deduplicate review-flagged helpers - 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. --- .../ai-openai/src/adapters/transcription.ts | 32 +++++++++++-------- testing/e2e/src/lib/request-body.ts | 21 ++++++++++++ testing/e2e/src/routes/api.otel-media.ts | 18 +---------- .../e2e/src/routes/api.otel-transcription.ts | 18 +---------- 4 files changed, 41 insertions(+), 48 deletions(-) create mode 100644 testing/e2e/src/lib/request-body.ts diff --git a/packages/ai-openai/src/adapters/transcription.ts b/packages/ai-openai/src/adapters/transcription.ts index dd9b0aef64..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,13 +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, - billed: { quantity: usage.seconds, unit: 'seconds' }, - durationSeconds: usage.seconds, - } + return durationUsage(usage.seconds) } const result: TokenUsage = { @@ -112,13 +122,7 @@ function buildTranscriptionUsage( // Whisper-1 uses duration-based billing if (duration !== undefined && duration > 0) { - return { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - billed: { quantity: duration, unit: 'seconds' }, - durationSeconds: duration, - } + return durationUsage(duration) } return undefined 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/routes/api.otel-media.ts b/testing/e2e/src/routes/api.otel-media.ts index dd454f33b0..049ff4cca8 100644 --- a/testing/e2e/src/routes/api.otel-media.ts +++ b/testing/e2e/src/routes/api.otel-media.ts @@ -4,23 +4,7 @@ import { otelMiddleware } from '@tanstack/ai/middlewares/otel' import type { Provider } from '@/lib/types' import { createImageAdapter } from '@/lib/media-providers' import { createLocalCaptureTracer } from '@/lib/otel-local-tracer' - -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 { 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 index c09dfd4605..6350261077 100644 --- a/testing/e2e/src/routes/api.otel-transcription.ts +++ b/testing/e2e/src/routes/api.otel-transcription.ts @@ -4,23 +4,7 @@ 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' - -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 { recordFromBody } from '@/lib/request-body' /** * Drives `generateTranscription` with `otelMiddleware` against the whisper From ee3b7055904b3e6ad8f7ec4372e7232e661be1cc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 11:17:09 +0200 Subject: [PATCH 3/3] feat: report billed usage from rerank, Seedance, and persistence Cohere and OpenRouter rerank now set usage.billed with unit units. Seedance video sets unit tokens. Persistence sums billed when both reports use the same unit. --- .changeset/billed-usage-unit.md | 5 ++++- docs/config.json | 3 ++- docs/media/video-generation.md | 2 +- docs/reference/interfaces/RerankResult.md | 7 ++++--- docs/reference/interfaces/VideoUrlResult.md | 4 ++-- docs/rerank/rerank.md | 13 +++++++++---- examples/ts-react-rerank/README.md | 3 ++- .../src/components/RerankPanel.tsx | 9 ++++++--- packages/ai-byteplus/src/adapters/video.ts | 9 ++++++--- packages/ai-byteplus/tests/video.test.ts | 2 ++ packages/ai-cohere/src/adapters/rerank.ts | 7 ++++++- packages/ai-cohere/tests/rerank-adapter.test.ts | 3 ++- packages/ai-openrouter/src/adapters/rerank.ts | 5 ++++- .../ai-openrouter/tests/rerank-adapter.test.ts | 1 + packages/ai-persistence/src/middleware.ts | 17 +++++++++++++++++ .../tests/with-persistence.test.ts | 6 ++++++ packages/ai/src/types.ts | 7 ++++--- packages/ai/tests/rerank.test.ts | 8 +++++++- 18 files changed, 85 insertions(+), 26 deletions(-) diff --git a/.changeset/billed-usage-unit.md b/.changeset/billed-usage-unit.md index 41aeedbe40..f213423187 100644 --- a/.changeset/billed-usage-unit.md +++ b/.changeset/billed-usage-unit.md @@ -5,6 +5,9 @@ '@tanstack/ai-grok': minor '@tanstack/ai-openai': minor '@tanstack/ai-byteplus': minor +'@tanstack/ai-cohere': minor +'@tanstack/ai-openrouter': minor +'@tanstack/ai-persistence': 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. +Add a self-describing `billed` field to `TokenUsage` so billed quantities carry the unit they are counted in (#816). `usage.billed` is `{ quantity, unit }` with a `BillingUnit` union (`'seconds'`, `'units'`, `'images'`, `'tokens'`, ... open-ended). The deprecated `unitsBilled` / `durationSeconds` counts are 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' }`, BytePlus Seedream images `{ quantity, unit: 'images' }`, BytePlus Seedance video `{ quantity, unit: 'tokens' }`, and Cohere/OpenRouter rerank `{ quantity, unit: 'units' }` (search units). Persistence sums `billed` when both reports use the same unit. `otelMiddleware` emits the pair as `tanstack.ai.usage.billed_quantity` / `tanstack.ai.usage.billed_unit` span attributes. diff --git a/docs/config.json b/docs/config.json index 58b285042b..22adc5c370 100644 --- a/docs/config.json +++ b/docs/config.json @@ -479,7 +479,8 @@ { "label": "Reranking", "to": "rerank/rerank", - "addedAt": "2026-06-25" + "addedAt": "2026-06-25", + "updatedAt": "2026-08-19" } ] }, diff --git a/docs/media/video-generation.md b/docs/media/video-generation.md index b43506da47..f2865b1c08 100644 --- a/docs/media/video-generation.md +++ b/docs/media/video-generation.md @@ -811,7 +811,7 @@ const { jobId } = await generateVideo({ Options are **model-specific and validated server-side**: Ark rejects an inapplicable field with a `400` instead of ignoring it. `service_tier` and `camera_fixed` are Seedance 1.x only, `frames` works on the 1.0-pro models, `draft` on 1.5-pro, `priority` on Seedance 2.5 and the 2.0 family, and `duration: -1` (let the model choose) on 2.5, 2.0 and 1.5-pro. Durations are 4–30s on Seedance 2.5, 4–15s on the 2.0 family, 4–12s on 1.5-pro and 2–12s on the 1.0-pro models. -**Seedance video URLs expire 24 hours after the task completes** (the task record is kept for seven days), so persist the bytes rather than the link. See the [BytePlus adapter](../adapters/byteplus#video-generation-seedance) for the full option table. +**Seedance video URLs expire 24 hours after the task completes** (the task record is kept for seven days), so persist the bytes rather than the link. See the [BytePlus adapter](../adapters/byteplus#video-generation-seedance) for the full option table. Completed jobs report `usage.billed` as `{ quantity, unit: 'tokens' }` (Seedance bills output tokens only). ##### Porting a Seedance call between providers diff --git a/docs/reference/interfaces/RerankResult.md b/docs/reference/interfaces/RerankResult.md index 9708c29863..8b2922b99b 100644 --- a/docs/reference/interfaces/RerankResult.md +++ b/docs/reference/interfaces/RerankResult.md @@ -90,6 +90,7 @@ usage: TokenUsage; Defined in: [packages/ai/src/types.ts:2085](https://github.com/TanStack/ai/blob/main/packages/ai/src/types.ts#L2085) Usage for the request. Rerank typically bills in provider-defined "search -units" (`usage.unitsBilled`) rather than tokens. Some providers (e.g. -OpenRouter) may also report `totalTokens` and `cost`; Cohere reports only -search units and leaves the token counts at 0. +units" (`usage.billed = { quantity, unit: 'units' }`) rather than tokens. Some +providers (for example OpenRouter) may also report `totalTokens` and `cost`. +Cohere reports only search units and leaves the token counts at 0. The +deprecated `unitsBilled` field is still populated for compatibility. diff --git a/docs/reference/interfaces/VideoUrlResult.md b/docs/reference/interfaces/VideoUrlResult.md index f9a9e4e752..ebe3b0c98c 100644 --- a/docs/reference/interfaces/VideoUrlResult.md +++ b/docs/reference/interfaces/VideoUrlResult.md @@ -82,5 +82,5 @@ Defined in: [packages/ai/src/types.ts:2469](https://github.com/TanStack/ai/blob/ **`Experimental`** 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 (for example fal) this carries `billed`, the +real billed quantity paired with its unit, so consumers can compute exact cost. diff --git a/docs/rerank/rerank.md b/docs/rerank/rerank.md index f3b5b299e8..b1e091e0f0 100644 --- a/docs/rerank/rerank.md +++ b/docs/rerank/rerank.md @@ -164,9 +164,10 @@ interface RerankResult { ranking: Array<{ index: number; score: number; document: TDocument }> // The documents reordered by relevance (ranking.map(r => r.document)). rerankedDocuments: Array - // Rerank typically bills in provider "search units" (usage.unitsBilled). - // Some providers (e.g. OpenRouter) also report totalTokens and cost; Cohere - // reports only search units and leaves token counts at 0. + // Rerank typically bills in provider "search units" + // (usage.billed = { quantity, unit: 'units' }). Some providers (for example + // OpenRouter) also report totalTokens and cost. Cohere reports only search + // units and leaves token counts at 0. usage: TokenUsage } ``` @@ -295,7 +296,11 @@ const result = await rerank({ { name: 'usage-logger', onUsage: (_ctx, usage) => { - console.log('search units billed:', usage.unitsBilled) + if (usage.billed) { + console.log( + `search units billed: ${usage.billed.quantity} ${usage.billed.unit}`, + ) + } }, }, ], diff --git a/examples/ts-react-rerank/README.md b/examples/ts-react-rerank/README.md index 0a84fc3f54..233464b718 100644 --- a/examples/ts-react-rerank/README.md +++ b/examples/ts-react-rerank/README.md @@ -67,7 +67,8 @@ result.ranking[0].index // number — position in the input array inside the server function, so nothing is exposed to the browser. **Usage is reported.** Rerank bills in search units rather than tokens, so the -result panel reads `usage.unitsBilled`. OpenRouter also reports `usage.cost`. +result panel reads `usage.billed` (`{ quantity, unit: 'units' }`). OpenRouter +also reports `usage.cost`. ## Files worth reading diff --git a/examples/ts-react-rerank/src/components/RerankPanel.tsx b/examples/ts-react-rerank/src/components/RerankPanel.tsx index d223bbae94..d7ef77f6a5 100644 --- a/examples/ts-react-rerank/src/components/RerankPanel.tsx +++ b/examples/ts-react-rerank/src/components/RerankPanel.tsx @@ -251,11 +251,14 @@ export default function RerankPanel() {
model
{result.model}
- {usage?.unitsBilled !== undefined && ( + {usage?.billed && (
-
search units billed
+
billed
- {usage.unitsBilled} + {usage.billed.quantity}{' '} + {usage.billed.unit === 'units' + ? `search unit${usage.billed.quantity === 1 ? '' : 's'}` + : usage.billed.unit}
)} diff --git a/packages/ai-byteplus/src/adapters/video.ts b/packages/ai-byteplus/src/adapters/video.ts index 7b25441fed..8f827946ac 100644 --- a/packages/ai-byteplus/src/adapters/video.ts +++ b/packages/ai-byteplus/src/adapters/video.ts @@ -95,9 +95,11 @@ function toTokenCount(value: number | string | undefined): number | undefined { /** * Maps a finished task's usage onto `TokenUsage`. * - * Seedance bills output only — the API documents input tokens as always 0 and - * `total_tokens` as equal to `completion_tokens` — so `promptTokens` is 0 and - * the completion count doubles as `unitsBilled`. + * Seedance bills output only. The API documents input tokens as always 0 and + * `total_tokens` as equal to `completion_tokens`, so `promptTokens` is 0 and + * the completion count is the billed quantity (`usage.billed` with + * `unit: 'tokens'`). The deprecated `unitsBilled` is still populated for + * backward compatibility. */ function buildBytePlusVideoUsage( usage: BytePlusVideoTaskUsage | undefined, @@ -115,6 +117,7 @@ function buildBytePlusVideoUsage( promptTokens: 0, completionTokens: completion, totalTokens: totalTokens ?? completion, + billed: { quantity: completion, unit: 'tokens' }, unitsBilled: completion, } } diff --git a/packages/ai-byteplus/tests/video.test.ts b/packages/ai-byteplus/tests/video.test.ts index 73a97bfca7..98048020b4 100644 --- a/packages/ai-byteplus/tests/video.test.ts +++ b/packages/ai-byteplus/tests/video.test.ts @@ -964,6 +964,7 @@ describe('getVideoUrl', () => { promptTokens: 0, completionTokens: 35800, totalTokens: 35800, + billed: { quantity: 35800, unit: 'tokens' }, unitsBilled: 35800, }) }) @@ -999,6 +1000,7 @@ describe('getVideoUrl', () => { expect((await adapter.getVideoUrl(JOB_ID)).usage).toMatchObject({ completionTokens: 35800, totalTokens: 35800, + billed: { quantity: 35800, unit: 'tokens' }, unitsBilled: 35800, }) }) diff --git a/packages/ai-cohere/src/adapters/rerank.ts b/packages/ai-cohere/src/adapters/rerank.ts index 5298175cfe..3a52e97225 100644 --- a/packages/ai-cohere/src/adapters/rerank.ts +++ b/packages/ai-cohere/src/adapters/rerank.ts @@ -124,7 +124,12 @@ export class CohereRerankAdapter< promptTokens: 0, completionTokens: 0, totalTokens: 0, - ...(searchUnits !== undefined ? { unitsBilled: searchUnits } : {}), + ...(searchUnits !== undefined + ? { + billed: { quantity: searchUnits, unit: 'units' }, + unitsBilled: searchUnits, + } + : {}), } return { diff --git a/packages/ai-cohere/tests/rerank-adapter.test.ts b/packages/ai-cohere/tests/rerank-adapter.test.ts index de6ce76887..436eae87fa 100644 --- a/packages/ai-cohere/tests/rerank-adapter.test.ts +++ b/packages/ai-cohere/tests/rerank-adapter.test.ts @@ -69,7 +69,7 @@ describe('CohereRerankAdapter', () => { }) }) - it('maps results to ranking and search_units to usage.unitsBilled', async () => { + it('maps results to ranking and search_units to usage.billed', async () => { fetchMock.mockResolvedValue(cohereResponse(defaultBody())) const result = await rerank({ @@ -83,6 +83,7 @@ describe('CohereRerankAdapter', () => { { index: 1, score: 0.98, document: documents[1] }, { index: 0, score: 0.12, document: documents[0] }, ]) + expect(result.usage.billed).toEqual({ quantity: 1, unit: 'units' }) expect(result.usage.unitsBilled).toBe(1) expect(result.usage.totalTokens).toBe(0) }) diff --git a/packages/ai-openrouter/src/adapters/rerank.ts b/packages/ai-openrouter/src/adapters/rerank.ts index c6b92055a1..ea0f4d4d7c 100644 --- a/packages/ai-openrouter/src/adapters/rerank.ts +++ b/packages/ai-openrouter/src/adapters/rerank.ts @@ -78,7 +78,10 @@ export class OpenRouterRerankAdapter< completionTokens: 0, totalTokens: response.usage?.totalTokens ?? 0, ...(response.usage?.searchUnits !== undefined - ? { unitsBilled: response.usage.searchUnits } + ? { + billed: { quantity: response.usage.searchUnits, unit: 'units' }, + unitsBilled: response.usage.searchUnits, + } : {}), ...(response.usage?.cost !== undefined ? { cost: response.usage.cost } diff --git a/packages/ai-openrouter/tests/rerank-adapter.test.ts b/packages/ai-openrouter/tests/rerank-adapter.test.ts index 5d1fa27649..11a3e7ee53 100644 --- a/packages/ai-openrouter/tests/rerank-adapter.test.ts +++ b/packages/ai-openrouter/tests/rerank-adapter.test.ts @@ -87,6 +87,7 @@ describe('OpenRouterRerankAdapter', () => { const result = await rerank({ adapter: adapter(), query: 'q', documents }) + expect(result.usage.billed).toEqual({ quantity: 1, unit: 'units' }) expect(result.usage.unitsBilled).toBe(1) expect(result.usage.cost).toBe(0.002) expect(result.usage.totalTokens).toBe(20) diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 25a0473a16..9612062b9e 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -35,6 +35,7 @@ import type { RunAgentResumeItem, StreamChunk, ToolApprovalResolution, + BilledUsage, TokenUsage, } from '@tanstack/ai' import type { @@ -1349,6 +1350,7 @@ function accumulateTokenUsage( next.durationSeconds, ) const unitsBilled = sumOptionalNumber(current.unitsBilled, next.unitsBilled) + const billed = accumulateBilled(current.billed, next.billed) const cost = sumOptionalNumber(current.cost, next.cost) return { @@ -1361,12 +1363,27 @@ function accumulateTokenUsage( ...(completionTokensDetails ? { completionTokensDetails } : {}), ...(durationSeconds !== undefined ? { durationSeconds } : {}), ...(unitsBilled !== undefined ? { unitsBilled } : {}), + ...(billed !== undefined ? { billed } : {}), ...(cost !== undefined ? { cost } : {}), ...(costDetails ? { costDetails } : {}), ...(providerUsageDetails ? { providerUsageDetails } : {}), } } +/** + * Sum billed quantities when both reports use the same unit. Different units + * cannot be added, so the later report wins. + */ +function accumulateBilled( + current: BilledUsage | undefined, + next: BilledUsage | undefined, +): BilledUsage | undefined { + if (!current) return next + if (!next) return current + if (current.unit !== next.unit) return next + return { quantity: current.quantity + next.quantity, unit: current.unit } +} + async function completeRun( runs: RunStore | undefined, runId: string, diff --git a/packages/ai-persistence/tests/with-persistence.test.ts b/packages/ai-persistence/tests/with-persistence.test.ts index 852b386696..5bae9528cb 100644 --- a/packages/ai-persistence/tests/with-persistence.test.ts +++ b/packages/ai-persistence/tests/with-persistence.test.ts @@ -178,6 +178,8 @@ describe('withPersistence (state-only)', () => { completionTokens: 2, totalTokens: 12, promptTokensDetails: { cachedTokens: 3 }, + billed: { quantity: 2, unit: 'units' }, + unitsBilled: 2, cost: 1, }, }, @@ -197,6 +199,8 @@ describe('withPersistence (state-only)', () => { totalTokens: 24, completionTokensDetails: { reasoningTokens: 2 }, providerUsageDetails: { requestId: 'final' }, + billed: { quantity: 1, unit: 'units' }, + unitsBilled: 1, cost: 2, }), ], @@ -220,6 +224,8 @@ describe('withPersistence (state-only)', () => { promptTokensDetails: { cachedTokens: 3 }, completionTokensDetails: { reasoningTokens: 2 }, providerUsageDetails: { requestId: 'final' }, + billed: { quantity: 3, unit: 'units' }, + unitsBilled: 3, cost: 3, }) }) diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index f7942c76ea..5060adbeff 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -2082,9 +2082,10 @@ export interface RerankResult { rerankedDocuments: Array /** * Usage for the request. Rerank typically bills in provider-defined "search - * units" (`usage.unitsBilled`) rather than tokens. Some providers (e.g. - * OpenRouter) may also report `totalTokens` and `cost`; Cohere reports only - * search units and leaves the token counts at 0. + * units" (`usage.billed = { quantity, unit: 'units' }`) rather than tokens. + * Some providers (e.g. OpenRouter) may also report `totalTokens` and `cost`. + * Cohere reports only search units and leaves the token counts at 0. + * The deprecated `unitsBilled` field is still populated for compatibility. */ usage: TokenUsage } diff --git a/packages/ai/tests/rerank.test.ts b/packages/ai/tests/rerank.test.ts index 2015fe625f..a09225cfa0 100644 --- a/packages/ai/tests/rerank.test.ts +++ b/packages/ai/tests/rerank.test.ts @@ -52,7 +52,11 @@ function ranked(...indices: Array): RerankAdapterResult { return { id: 'rr-1', ranking: indices.map((index, i) => ({ index, score: 1 - i * 0.1 })), - usage: { ...zeroUsage, unitsBilled: 1 }, + usage: { + ...zeroUsage, + billed: { quantity: 1, unit: 'units' }, + unitsBilled: 1, + }, } } @@ -107,6 +111,7 @@ describe('rerank() activity', () => { 'rainy afternoon in the city', 'sunny day at the beach', ]) + expect(result.usage.billed).toEqual({ quantity: 1, unit: 'units' }) expect(result.usage.unitsBilled).toBe(1) }) @@ -156,6 +161,7 @@ describe('rerank() activity', () => { expect(events.start).toHaveLength(1) expect(events.start[0]!.activity).toBe('rerank') expect(events.start[0]!.provider).toBe('mock') + expect(events.usage[0]!.billed).toEqual({ quantity: 1, unit: 'units' }) expect(events.usage[0]!.unitsBilled).toBe(1) expect(events.finish).toHaveLength(1) expect(events.error).toHaveLength(0)