Skip to content

Commit 22c9b42

Browse files
tombeckenhamclaudeautofix-ci[bot]
authored
feat(fal): surface billed cost as result.usage.unitsBilled (#723)
* feat(fal): surface billed cost as result.usage.unitsBilled The fal adapters discarded fal's response headers, so the actual billed cost of a generation was unrecoverable through the SDK. fal returns the real billed quantity in the `x-fal-billable-units` header on the result fetch; this surfaces it as `result.usage.unitsBilled` so consumers can compute exact media-generation cost without wrapping `fetch` themselves. - `TokenUsage` gains an optional `unitsBilled` (a bare count of priced units, sibling to `durationSeconds`; the unit name itself is provider -defined and looked up via the pricing API, not carried here). - A `config.fetch` wrapper reads `x-fal-billable-units` off every fal response, keyed by `x-fal-request-id` (the same value the client surfaces as `Result.requestId`), so the adapter's lookup always matches the fetch the units came from. `config.fetch` is used rather than `responseHandler` because the queue ops clobber a global handler. - All five fal media adapters (image, audio, video, speech, transcription) populate `result.usage.unitsBilled` when fal reports it. - `VideoUrlResult` gains a `usage` slot; `getVideoJobStatus` now emits the `video:usage` event and returns `usage` on completion. Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * feat(examples): show fal unitsBilled in ts-react-media Surface the new `result.usage.unitsBilled` in the media example so the billed quantity is visible after a generation — a caption under each generated image/video ("Billed N fal units"). Verified against the live fal API: a fal-ai/flux/schnell generation reports unitsBilled: 1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(examples): use a runtime-valid size for grok-imagine-image fal's generated `size`/`resolution` type for `xai/grok-imagine-image` offers `16:9_1K` / `16:9_4K`, but the live API rejects those resolutions ("Input should be '1k' or '2k'") — the vendor enum is out of sync with the API. `'16:9_4K'` therefore type-checked but 422'd at runtime. Pass `aspect_ratio: '16:9'` via modelOptions instead and let the endpoint default the resolution; verified against the live fal API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(fal): inject fetch instead of overriding the global Address CodeRabbit + review feedback: dependency-inject the underlying fetch rather than mutating globals. - `createBillingFetch(baseFetch = globalThis.fetch)` now takes the fetch to delegate to, and `FalClientConfig` gains an optional `fetch` override that `configureFalClient` wraps for usage capture. - The E2E route passes a per-request redirecting `fetch` via `falImage(model, { fetch })` instead of swapping `globalThis.fetch` — removing the concurrency race CodeRabbit flagged (no global mutation, no try/finally restore). - `billing.test.ts` injects a fake fetch directly instead of `vi.stubGlobal('fetch')`. - Changeset: reword "billed cost" → "billed units" to match the surfaced `usage.unitsBilled` field. Verified: full `pnpm test:pr` green, fal E2E spec green, and a live fal-ai/flux/schnell call still reports unitsBilled via the default (no-override) path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: apply automated fixes --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent afb2960 commit 22c9b42

33 files changed

Lines changed: 852 additions & 29 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@tanstack/ai-event-client': minor
3+
'@tanstack/ai-fal': minor
4+
'@tanstack/ai': minor
5+
---
6+
7+
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.
8+
9+
- `TokenUsage` gains an optional `unitsBilled` field for usage-based (non-token) billing, denominated in the provider's priced unit.
10+
- `falImage`, `falAudio`, `falVideo`, `falSpeech`, and `falTranscription` populate `result.usage.unitsBilled` when fal reports it.
11+
- `VideoUrlResult` gains an optional `usage` slot; `getVideoJobStatus` now emits the `video:usage` event and returns `usage` when the completed result reports billed units.

docs/config.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,17 +242,20 @@
242242
{
243243
"label": "Audio Generation",
244244
"to": "media/audio-generation",
245-
"addedAt": "2026-04-23"
245+
"addedAt": "2026-04-23",
246+
"updatedAt": "2026-06-08"
246247
},
247248
{
248249
"label": "Image Generation",
249250
"to": "media/image-generation",
250-
"addedAt": "2026-04-15"
251+
"addedAt": "2026-04-15",
252+
"updatedAt": "2026-06-08"
251253
},
252254
{
253255
"label": "Video Generation",
254256
"to": "media/video-generation",
255-
"addedAt": "2026-04-15"
257+
"addedAt": "2026-04-15",
258+
"updatedAt": "2026-06-08"
256259
},
257260
{
258261
"label": "Generation Hooks",

docs/media/audio-generation.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ interface AudioGenerationResult {
118118
duration?: number
119119
}
120120
// Canonical TokenUsage (same shape as chat), present when the provider
121-
// reports it (e.g. Gemini Lyria via generateContent).
121+
// reports it (e.g. Gemini Lyria via generateContent). Usage-billed providers
122+
// (fal) instead surface `usage.unitsBilled` — the real billed quantity read
123+
// from fal's `x-fal-billable-units` result header. Multiply by the endpoint's
124+
// unit price (fal pricing API) for the exact cost.
122125
usage?: TokenUsage
123126
}
124127
```

docs/media/image-generation.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ interface ImageGenerationResult {
203203
images: GeneratedImage[] // Array of generated images
204204
// Canonical TokenUsage (same shape as chat). Token-billed models also surface
205205
// a per-modality breakdown on `promptTokensDetails` (e.g. text vs image input
206-
// tokens for gpt-image-1).
206+
// tokens for gpt-image-1). Usage-billed providers (fal) instead surface
207+
// `usage.unitsBilled` — see the note below.
207208
usage?: TokenUsage
208209
}
209210

@@ -214,6 +215,25 @@ interface GeneratedImage {
214215
}
215216
```
216217

218+
> **Cost tracking (fal):** fal bills by usage-based units rather than tokens. The
219+
> fal image adapter surfaces the real billed quantity as `usage.unitsBilled`
220+
> (read from fal's `x-fal-billable-units` result header). Multiply it by the
221+
> endpoint's unit price from
222+
> `GET https://api.fal.ai/v1/models/pricing?endpoint_id=…` for the exact cost —
223+
> no `fetch` interceptor needed.
224+
225+
```typescript
226+
const result = await generateImage({
227+
adapter: falImage('fal-ai/flux/dev'),
228+
prompt: 'a serene mountain lake',
229+
})
230+
231+
if (result.usage?.unitsBilled != null) {
232+
const cost = result.usage.unitsBilled * unitPrice // unitPrice from fal pricing API
233+
console.log(`Billed ${result.usage.unitsBilled} units (~$${cost})`)
234+
}
235+
```
236+
217237
## Model Availability
218238

219239
### OpenAI Models

docs/media/video-generation.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ const { jobId } = await generateVideo({
408408

409409
## Response Types
410410

411-
> **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`.
411+
> **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`.
412412
413413
### VideoJobResult (from create)
414414

@@ -437,9 +437,20 @@ interface VideoUrlResult {
437437
jobId: string
438438
url: string // URL to download/stream the video
439439
expiresAt?: Date // When the URL expires
440+
// Usage for the completed generation, when the adapter reports it. fal
441+
// populates `usage.unitsBilled` from its `x-fal-billable-units` header.
442+
usage?: TokenUsage
440443
}
441444
```
442445

446+
> **Cost tracking (fal):** fal bills media generation by usage-based units
447+
> rather than tokens. The fal adapters surface the real billed quantity as
448+
> `usage.unitsBilled` (denominated in the endpoint's priced unit). Combine it
449+
> with the endpoint's unit price from
450+
> `GET https://api.fal.ai/v1/models/pricing?endpoint_id=…` to compute the exact
451+
> cost (`unitsBilled * unitPrice`). The same `usage.unitsBilled` is surfaced
452+
> on image, audio, speech, and transcription results.
453+
443454
## Model Variants
444455

445456
| Model | Description | Use Case |

examples/ts-react-media/src/components/ImageGenerator.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,13 +209,24 @@ export default function ImageGenerator({
209209
{modelResult.status === 'success' &&
210210
modelResult.result &&
211211
modelResult.result.images.length > 0 && (
212-
<div className="rounded-lg overflow-hidden border border-gray-700">
213-
<img
214-
src={getImageSrc(modelResult.result.images[0]!)}
215-
alt={`Generated by ${model?.name ?? modelId}`}
216-
className="w-full h-auto"
217-
/>
218-
</div>
212+
<>
213+
<div className="rounded-lg overflow-hidden border border-gray-700">
214+
<img
215+
src={getImageSrc(modelResult.result.images[0]!)}
216+
alt={`Generated by ${model?.name ?? modelId}`}
217+
className="w-full h-auto"
218+
/>
219+
</div>
220+
{modelResult.result.usage?.unitsBilled != null && (
221+
<p className="text-xs text-gray-500">
222+
Billed {modelResult.result.usage.unitsBilled} fal unit
223+
{modelResult.result.usage.unitsBilled === 1
224+
? ''
225+
: 's'}{' '}
226+
— multiply by the endpoint unit price for USD cost
227+
</p>
228+
)}
229+
</>
219230
)}
220231
</div>
221232
)

examples/ts-react-media/src/components/VideoGenerator.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type JobState =
2020
model: string
2121
progress?: number | undefined
2222
}
23-
| { status: 'completed'; url: string }
23+
| { status: 'completed'; url: string; unitsBilled?: number }
2424
| { status: 'error'; message: string }
2525

2626
interface VideoGeneratorProps {
@@ -95,7 +95,11 @@ export default function VideoGenerator({
9595

9696
setJobStates((prev) => ({
9797
...prev,
98-
[model]: { status: 'completed', url: url },
98+
[model]: {
99+
status: 'completed',
100+
url: url,
101+
unitsBilled: urlResult.usage?.unitsBilled,
102+
},
99103
}))
100104
} else if (status.status === 'processing') {
101105
setJobStates((prev) => ({
@@ -387,15 +391,24 @@ export default function VideoGenerator({
387391
</div>
388392
)}
389393
{state.status === 'completed' && (
390-
<div className="rounded-lg overflow-hidden border border-gray-700">
391-
<video
392-
src={state.url}
393-
controls
394-
autoPlay
395-
loop
396-
className="w-full h-auto"
397-
/>
398-
</div>
394+
<>
395+
<div className="rounded-lg overflow-hidden border border-gray-700">
396+
<video
397+
src={state.url}
398+
controls
399+
autoPlay
400+
loop
401+
className="w-full h-auto"
402+
/>
403+
</div>
404+
{state.unitsBilled != null && (
405+
<p className="text-xs text-gray-500">
406+
Billed {state.unitsBilled} fal unit
407+
{state.unitsBilled === 1 ? '' : 's'} — multiply by the
408+
endpoint unit price for USD cost
409+
</p>
410+
)}
411+
</>
399412
)}
400413
</div>
401414
)

examples/ts-react-media/src/lib/server-functions.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,17 @@ export const generateImageFn = createServerFn({ method: 'POST' })
2929
})
3030
}
3131
case 'xai/grok-imagine-image': {
32+
// NOTE: fal's generated `size` type for this model only offers
33+
// `16:9_1K` / `16:9_4K`, but the live API rejects those resolutions
34+
// ("Input should be '1k' or '2k'") — fal's published enum is out of
35+
// sync with its API, so `'16:9_4K'` type-checks yet 422s at runtime.
36+
// Pass aspect_ratio via modelOptions and let the endpoint pick its
37+
// default resolution, which both type-checks and works at runtime.
3238
return generateImage({
3339
adapter: falImage('xai/grok-imagine-image'),
3440
prompt: data.prompt,
3541
numberOfImages: 1,
36-
size: '16:9_4K',
42+
modelOptions: { aspect_ratio: '16:9' },
3743
})
3844
}
3945
case 'fal-ai/flux-2/klein/9b': {

packages/ai-event-client/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ export interface TokenUsage<TProviderDetails = ProviderUsageDetails> {
223223
completionTokensDetails?: CompletionTokensDetails
224224
/** Duration in seconds for duration-based billing (e.g., Whisper transcription) */
225225
durationSeconds?: number
226+
/**
227+
* Number of priced units actually billed, for usage-based (non-token) billing.
228+
* This is a bare count, not a cost and not a unit name — the unit itself
229+
* (megapixels, seconds, images, …) is provider-defined and not carried here;
230+
* providers typically expose it via a separate pricing API. Surfaced for media
231+
* generation, where there are no tokens: fal returns this count in its
232+
* `x-fal-billable-units` response header. Multiply by the unit price to get the
233+
* exact cost (`unitsBilled * unitPrice`). The unit-priced analogue of
234+
* `durationSeconds` (the time-priced case); both are quantities, distinct from
235+
* the monetary `cost` / `costDetails`.
236+
*/
237+
unitsBilled?: number
226238
/** Provider-specific usage details not covered by standard fields */
227239
providerUsageDetails?: TProviderDetails
228240
/** Provider-reported cost for the request, when available. */

packages/ai-fal/src/adapters/audio.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { fal } from '@fal-ai/client'
22
import { BaseAudioAdapter } from '@tanstack/ai/adapters'
33
import {
4+
buildFalUsage,
45
configureFalClient,
56
deriveAudioContentType,
7+
takeBillableUnits,
68
generateId as utilGenerateId,
79
} from '../utils'
810
import type { OutputType, Result } from '@fal-ai/client'
@@ -133,13 +135,16 @@ export class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<
133135
throw new Error('Audio URL not found in fal audio generation response')
134136
}
135137

138+
const usage = buildFalUsage(takeBillableUnits(response.requestId))
139+
136140
return {
137141
id: response.requestId || this.generateId(),
138142
model: this.model,
139143
audio: {
140144
url: audioUrl,
141145
contentType: deriveAudioContentType(contentType, audioUrl),
142146
},
147+
...(usage ? { usage } : {}),
143148
}
144149
}
145150
}

0 commit comments

Comments
 (0)