diff --git a/.changeset/billed-usage-unit.md b/.changeset/billed-usage-unit.md new file mode 100644 index 0000000000..f213423187 --- /dev/null +++ b/.changeset/billed-usage-unit.md @@ -0,0 +1,13 @@ +--- +'@tanstack/ai-event-client': minor +'@tanstack/ai': minor +'@tanstack/ai-fal': minor +'@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 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/adapters/grok.md b/docs/adapters/grok.md index c95c1a9f60..41dc55a4ca 100644 --- a/docs/adapters/grok.md +++ b/docs/adapters/grok.md @@ -366,7 +366,7 @@ const extension = await generateVideo({ Both return the usual `{ jobId }` and are polled like any other Grok video job. -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 6ab97268ed..22adc5c370 100644 --- a/docs/config.json +++ b/docs/config.json @@ -440,7 +440,7 @@ "label": "Audio Generation", "to": "media/audio-generation", "addedAt": "2026-04-23", - "updatedAt": "2026-08-04" + "updatedAt": "2026-08-08" }, { "label": "Image Generation", @@ -479,7 +479,8 @@ { "label": "Reranking", "to": "rerank/rerank", - "addedAt": "2026-06-25" + "addedAt": "2026-06-25", + "updatedAt": "2026-08-19" } ] }, @@ -507,7 +508,7 @@ "label": "OpenTelemetry", "to": "advanced/otel", "addedAt": "2026-05-08", - "updatedAt": "2026-08-06" + "updatedAt": "2026-08-08" } ] }, @@ -823,7 +824,7 @@ "label": "Grok (xAI)", "to": "adapters/grok", "addedAt": "2026-04-15", - "updatedAt": "2026-08-18" + "updatedAt": "2026-08-19" }, { "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 c4918e6c88..667cde89de 100644 --- a/docs/media/image-generation.md +++ b/docs/media/image-generation.md @@ -705,7 +705,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; } @@ -717,9 +717,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. @@ -733,9 +733,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 83e6cae28e..f2865b1c08 100644 --- a/docs/media/video-generation.md +++ b/docs/media/video-generation.md @@ -786,7 +786,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 @@ -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 @@ -921,19 +921,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/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-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 f76b79ef01..c01164f597 100644 --- a/examples/ts-react-media/src/lib/server-functions.ts +++ b/examples/ts-react-media/src/lib/server-functions.ts @@ -339,8 +339,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/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/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/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/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-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-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 90709fdd8f..9131ef80eb 100644 --- a/packages/ai-grok/src/adapters/video.ts +++ b/packages/ai-grok/src/adapters/video.ts @@ -87,7 +87,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 }), } } @@ -123,7 +126,7 @@ function buildGrokVideoUsage( * `video` prompt part and `modelOptions.mode: 'edit' | 'extend'` * (`/v1/videos/edits` / `/v1/videos/extensions`; in extend mode * `duration` is the added tail) - * - 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 d31188b07e..3f9fdd7a5a 100644 --- a/packages/ai-grok/tests/video-adapter.test.ts +++ b/packages/ai-grok/tests/video-adapter.test.ts @@ -1203,6 +1203,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-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/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index ad1618b376..3bd1f6d937 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -561,7 +561,7 @@ durations 4/8/12s, single `input_reference` image prompt part), `grokVideo(...)` outputs inherit the source clip's properties, so `size`/`aspect_ratio`/`resolution` throw in both modes and `duration` throws in edit mode — pass none of them there; generation uses the aspect-ratio size template like `'16:9_720p'` (1080p is 1.5-only), -integer durations 1-15s, reports `usage.unitsBilled` seconds and exact `usage.cost`), `byteplusVideo(...)` (Seedance — +integer durations 1-15s, reports `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), @@ -620,10 +620,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' @@ -634,10 +635,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 b73fafb667..5060adbeff 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, @@ -1106,6 +1108,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, @@ -2078,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 } @@ -2463,8 +2468,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/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) diff --git a/testing/e2e/global-setup.ts b/testing/e2e/global-setup.ts index bae3b021ad..f2f4d639c5 100644 --- a/testing/e2e/global-setup.ts +++ b/testing/e2e/global-setup.ts @@ -148,10 +148,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 d6cbc88372..42035ed4d9 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' @@ -224,6 +225,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', @@ -498,6 +504,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 @@ -570,6 +577,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 @@ -643,6 +651,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 @@ -717,6 +726,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' @@ -789,6 +799,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' @@ -861,6 +872,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' @@ -934,6 +946,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 @@ -1145,6 +1158,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' @@ -1555,6 +1575,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,