Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fal-billable-units-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/ai-event-client': minor
'@tanstack/ai-fal': minor
'@tanstack/ai': minor
---

Surface fal's billed units as `result.usage`. The fal adapters now read fal's `x-fal-billable-units` response header off the result fetch and expose the billed quantity (`usage.unitsBilled`) on the generation result, so consumers can compute exact media-generation cost without wrapping `fetch` themselves.

- `TokenUsage` gains an optional `unitsBilled` field for usage-based (non-token) billing, denominated in the provider's priced unit.
- `falImage`, `falAudio`, `falVideo`, `falSpeech`, and `falTranscription` populate `result.usage.unitsBilled` when fal reports it.
- `VideoUrlResult` gains an optional `usage` slot; `getVideoJobStatus` now emits the `video:usage` event and returns `usage` when the completed result reports billed units.
9 changes: 6 additions & 3 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,17 +242,20 @@
{
"label": "Audio Generation",
"to": "media/audio-generation",
"addedAt": "2026-04-23"
"addedAt": "2026-04-23",
"updatedAt": "2026-06-08"
},
{
"label": "Image Generation",
"to": "media/image-generation",
"addedAt": "2026-04-15"
"addedAt": "2026-04-15",
"updatedAt": "2026-06-08"
},
{
"label": "Video Generation",
"to": "media/video-generation",
"addedAt": "2026-04-15"
"addedAt": "2026-04-15",
"updatedAt": "2026-06-08"
},
{
"label": "Generation Hooks",
Expand Down
5 changes: 4 additions & 1 deletion docs/media/audio-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ interface AudioGenerationResult {
duration?: number
}
// Canonical TokenUsage (same shape as chat), present when the provider
// reports it (e.g. Gemini Lyria via generateContent).
// 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.
usage?: TokenUsage
}
```
Expand Down
22 changes: 21 additions & 1 deletion docs/media/image-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ interface ImageGenerationResult {
images: GeneratedImage[] // Array of generated images
// 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).
// tokens for gpt-image-1). Usage-billed providers (fal) instead surface
// `usage.unitsBilled` β€” see the note below.
usage?: TokenUsage
}

Expand All @@ -214,6 +215,25 @@ 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
> `GET https://api.fal.ai/v1/models/pricing?endpoint_id=…` for the exact cost β€”
> no `fetch` interceptor needed.

```typescript
const result = await generateImage({
adapter: falImage('fal-ai/flux/dev'),
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})`)
}
```

## Model Availability

### OpenAI Models
Expand Down
13 changes: 12 additions & 1 deletion docs/media/video-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ const { jobId } = await generateVideo({

## Response Types

> **Note:** The interfaces below are the underlying adapter-level types. The `getVideoJobStatus()` helper returns a single merged object, `{ status, progress?, url?, error? }` β€” it does not return `jobId` or `expiresAt`.
> **Note:** The interfaces below are the underlying adapter-level types. The `getVideoJobStatus()` helper returns a single merged object, `{ status, progress?, url?, error?, usage? }` β€” it does not return `jobId` or `expiresAt`.

### VideoJobResult (from create)

Expand Down Expand Up @@ -437,9 +437,20 @@ 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?: 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.

## Model Variants

| Model | Description | Use Case |
Expand Down
25 changes: 18 additions & 7 deletions examples/ts-react-media/src/components/ImageGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,24 @@ export default function ImageGenerator({
{modelResult.status === 'success' &&
modelResult.result &&
modelResult.result.images.length > 0 && (
<div className="rounded-lg overflow-hidden border border-gray-700">
<img
src={getImageSrc(modelResult.result.images[0]!)}
alt={`Generated by ${model?.name ?? modelId}`}
className="w-full h-auto"
/>
</div>
<>
<div className="rounded-lg overflow-hidden border border-gray-700">
<img
src={getImageSrc(modelResult.result.images[0]!)}
alt={`Generated by ${model?.name ?? modelId}`}
className="w-full h-auto"
/>
</div>
{modelResult.result.usage?.unitsBilled != null && (
<p className="text-xs text-gray-500">
Billed {modelResult.result.usage.unitsBilled} fal unit
{modelResult.result.usage.unitsBilled === 1
? ''
: 's'}{' '}
β€” multiply by the endpoint unit price for USD cost
</p>
)}
</>
)}
</div>
)
Expand Down
35 changes: 24 additions & 11 deletions examples/ts-react-media/src/components/VideoGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type JobState =
model: string
progress?: number | undefined
}
| { status: 'completed'; url: string }
| { status: 'completed'; url: string; unitsBilled?: number }
| { status: 'error'; message: string }

interface VideoGeneratorProps {
Expand Down Expand Up @@ -95,7 +95,11 @@ export default function VideoGenerator({

setJobStates((prev) => ({
...prev,
[model]: { status: 'completed', url: url },
[model]: {
status: 'completed',
url: url,
unitsBilled: urlResult.usage?.unitsBilled,
},
}))
} else if (status.status === 'processing') {
setJobStates((prev) => ({
Expand Down Expand Up @@ -387,15 +391,24 @@ export default function VideoGenerator({
</div>
)}
{state.status === 'completed' && (
<div className="rounded-lg overflow-hidden border border-gray-700">
<video
src={state.url}
controls
autoPlay
loop
className="w-full h-auto"
/>
</div>
<>
<div className="rounded-lg overflow-hidden border border-gray-700">
<video
src={state.url}
controls
autoPlay
loop
className="w-full h-auto"
/>
</div>
{state.unitsBilled != null && (
<p className="text-xs text-gray-500">
Billed {state.unitsBilled} fal unit
{state.unitsBilled === 1 ? '' : 's'} β€” multiply by the
endpoint unit price for USD cost
</p>
)}
</>
)}
</div>
)
Expand Down
8 changes: 7 additions & 1 deletion examples/ts-react-media/src/lib/server-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ export const generateImageFn = createServerFn({ method: 'POST' })
})
}
case 'xai/grok-imagine-image': {
// NOTE: fal's generated `size` type for this model only offers
// `16:9_1K` / `16:9_4K`, but the live API rejects those resolutions
// ("Input should be '1k' or '2k'") β€” fal's published enum is out of
// sync with its API, so `'16:9_4K'` type-checks yet 422s at runtime.
// Pass aspect_ratio via modelOptions and let the endpoint pick its
// default resolution, which both type-checks and works at runtime.
return generateImage({
adapter: falImage('xai/grok-imagine-image'),
prompt: data.prompt,
numberOfImages: 1,
size: '16:9_4K',
modelOptions: { aspect_ratio: '16:9' },
})
}
case 'fal-ai/flux-2/klein/9b': {
Expand Down
12 changes: 12 additions & 0 deletions packages/ai-event-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,18 @@ export interface TokenUsage<TProviderDetails = ProviderUsageDetails> {
completionTokensDetails?: CompletionTokensDetails
/** Duration in seconds for duration-based billing (e.g., Whisper transcription) */
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`.
*/
unitsBilled?: number
/** Provider-specific usage details not covered by standard fields */
providerUsageDetails?: TProviderDetails
/** Provider-reported cost for the request, when available. */
Expand Down
5 changes: 5 additions & 0 deletions packages/ai-fal/src/adapters/audio.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { fal } from '@fal-ai/client'
import { BaseAudioAdapter } from '@tanstack/ai/adapters'
import {
buildFalUsage,
configureFalClient,
deriveAudioContentType,
takeBillableUnits,
generateId as utilGenerateId,
} from '../utils'
import type { OutputType, Result } from '@fal-ai/client'
Expand Down Expand Up @@ -133,13 +135,16 @@ export class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<
throw new Error('Audio URL not found in fal audio generation response')
}

const usage = buildFalUsage(takeBillableUnits(response.requestId))

return {
id: response.requestId || this.generateId(),
model: this.model,
audio: {
url: audioUrl,
contentType: deriveAudioContentType(contentType, audioUrl),
},
...(usage ? { usage } : {}),
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion packages/ai-fal/src/adapters/image.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { fal } from '@fal-ai/client'
import { BaseImageAdapter } from '@tanstack/ai/adapters'
import { configureFalClient, generateId as utilGenerateId } from '../utils'
import {
buildFalUsage,
configureFalClient,
takeBillableUnits,
generateId as utilGenerateId,
} from '../utils'
import { mapSizeToFalFormat } from '../image/image-provider-options'
import type { OutputType, Result } from '@fal-ai/client'
import type { FalClientConfig } from '../utils'
Expand Down Expand Up @@ -120,10 +125,13 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
)
}

const usage = buildFalUsage(takeBillableUnits(response.requestId))

return {
id: response.requestId || this.generateId(),
model: this.model,
images,
...(usage ? { usage } : {}),
}
}

Expand Down
5 changes: 5 additions & 0 deletions packages/ai-fal/src/adapters/speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { fal } from '@fal-ai/client'
import { BaseTTSAdapter } from '@tanstack/ai/adapters'
import {
arrayBufferToBase64,
buildFalUsage,
configureFalClient,
extractUrlExtension,
takeBillableUnits,
generateId as utilGenerateId,
} from '../utils'
import type { OutputType, Result } from '@fal-ai/client'
Expand Down Expand Up @@ -133,12 +135,15 @@ export class FalSpeechAdapter<TModel extends FalModel> extends BaseTTSAdapter<
safeUrlExtension || contentTypeMime?.split('/')[1] || 'wav'
const format = rawFormat === 'mpeg' ? 'mp3' : rawFormat

const usage = buildFalUsage(takeBillableUnits(response.requestId))

return {
id: response.requestId || this.generateId(),
model: this.model,
audio: base64,
format,
contentType: contentTypeMime || `audio/${format}`,
...(usage ? { usage } : {}),
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/ai-fal/src/adapters/transcription.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { fal } from '@fal-ai/client'
import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters'
import {
buildFalUsage,
configureFalClient,
dataUrlToBlob,
takeBillableUnits,
generateId as utilGenerateId,
} from '../utils'
import type { OutputType, Result } from '@fal-ai/client'
Expand Down Expand Up @@ -151,12 +153,15 @@ export class FalTranscriptionAdapter<
(data.inferred_languages as Array<string> | undefined)?.[0] ||
(data.languages as Array<string> | undefined)?.[0]

const usage = buildFalUsage(takeBillableUnits(response.requestId))

return {
id: response.requestId || this.generateId(),
model: this.model,
text,
...(language !== undefined ? { language } : {}),
...(segments !== undefined ? { segments } : {}),
...(usage ? { usage } : {}),
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion packages/ai-fal/src/adapters/video.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { fal } from '@fal-ai/client'
import { BaseVideoAdapter } from '@tanstack/ai/adapters'
import { configureFalClient, generateId as utilGenerateId } from '../utils'
import {
buildFalUsage,
configureFalClient,
takeBillableUnits,
generateId as utilGenerateId,
} from '../utils'
import { mapVideoSizeToFalFormat } from '../video/video-provider-options'
import type {
VideoGenerationOptions,
Expand Down Expand Up @@ -163,9 +168,12 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
throw new Error('Video URL not found in response')
}

const usage = buildFalUsage(takeBillableUnits(result.requestId))

return {
jobId,
url,
...(usage ? { usage } : {}),
}
}

Expand Down
Loading
Loading