Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 cost as `result.usage`. The fal adapters now read fal's `x-fal-billable-units` response header off the result fetch and expose the real billed quantity on the generation result, so consumers can compute exact media-generation cost without wrapping `fetch` themselves.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

- `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
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
114 changes: 114 additions & 0 deletions packages/ai-fal/src/utils/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { TokenUsage } from '@tanstack/ai'

/**
* Response header fal sets on a queue *result* fetch carrying the real billed
* quantity for the generation, denominated in the endpoint's priced unit.
*/
const FAL_BILLABLE_UNITS_HEADER = 'x-fal-billable-units'

/**
* Response header fal sets carrying the request id. The fal client surfaces this
* same value as `Result.requestId`, so keying captured billable units by it
* guarantees the adapter's lookup matches the fetch the units came from β€” no URL
* parsing or global correlation registry of our own design needed.
*/
const FAL_REQUEST_ID_HEADER = 'x-fal-request-id'

/**
* Upper bound on retained, not-yet-consumed billable-unit entries. Each
* successful generation reads-and-deletes its entry (see {@link takeBillableUnits}),
* so this only guards against an unbounded leak when a result fetch records units
* but the adapter never resolves (e.g. it throws before reading). When the cap is
* exceeded the oldest entry is evicted (Map preserves insertion order).
*/
const MAX_PENDING_ENTRIES = 256

const billableUnitsByRequestId = new Map<string, number>()

/**
* Parse the `x-fal-billable-units` header value into a finite number. Returns
* `undefined` for a missing or non-numeric value so callers can skip attaching
* usage rather than surfacing `NaN`.
*/
export function parseBillableUnits(value: string | null): number | undefined {
if (value == null || value === '') return undefined
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}

/**
* Record the billable units carried by a fal result response, keyed by the
* request id from the same response. Reading headers does not consume the body,
* so the response can be returned to the fal client untouched.
*/
export function recordBillableUnitsFromResponse(response: Response): void {
const units = parseBillableUnits(
response.headers.get(FAL_BILLABLE_UNITS_HEADER),
)
if (units == null) return
const requestId = response.headers.get(FAL_REQUEST_ID_HEADER)
if (!requestId) return
if (
billableUnitsByRequestId.size >= MAX_PENDING_ENTRIES &&
!billableUnitsByRequestId.has(requestId)
) {
const oldest = billableUnitsByRequestId.keys().next().value
if (oldest !== undefined) billableUnitsByRequestId.delete(oldest)
}
billableUnitsByRequestId.set(requestId, units)
}

/**
* Read and remove the billable units recorded for a request id. Removing on read
* keeps the registry from growing across the lifetime of the process.
*/
export function takeBillableUnits(
requestId: string | undefined,
): number | undefined {
if (!requestId) return undefined
const units = billableUnitsByRequestId.get(requestId)
if (units !== undefined) billableUnitsByRequestId.delete(requestId)
return units
}

/**
* 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.
*/
export function buildFalUsage(
unitsBilled: number | undefined,
): TokenUsage | undefined {
if (unitsBilled == null) return undefined
return {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
unitsBilled,
}
}

/**
* Wrap the global `fetch` so every fal request's response is inspected for the
* `x-fal-billable-units` header before being returned untouched. Installed as
* fal's `config.fetch`, which (unlike a global `responseHandler`) is honoured for
* every request β€” the fal client forces `resultResponseHandler` per queue
* operation, clobbering any configured response handler.
*
* `globalThis.fetch` is resolved per call (late binding) rather than captured, so
* a host that swaps the global implementation (test harnesses, instrumented
* runtimes) is still respected.
*/
export function createBillingFetch(): typeof fetch {
return async (input, init) => {
const response = await globalThis.fetch(input, init)
try {
recordBillableUnitsFromResponse(response)
} catch {
// Capturing usage must never break the underlying request.
}
return response
}
}
4 changes: 4 additions & 0 deletions packages/ai-fal/src/utils/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fal } from '@fal-ai/client'
import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils'
import { createBillingFetch } from './billing'

export interface FalClientConfig {
apiKey: string
Expand All @@ -14,6 +15,9 @@ export function configureFalClient(config?: FalClientConfig): void {
const apiKey = config?.apiKey ?? getFalApiKeyFromEnv()
fal.config({
credentials: apiKey,
// Read fal's `x-fal-billable-units` header off every response so adapters
// can surface the real billed cost as `result.usage`. See ./billing.ts.
fetch: createBillingFetch(),
...(config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}),
})
}
Expand Down
Loading
Loading