@tanstack/ai-fal 0.7.23 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { fal } from "@fal-ai/client";
2
2
  import { BaseAudioAdapter } from "@tanstack/ai/adapters";
3
3
  import { configureFalClient, generateId, deriveAudioContentType } from "../utils/client.js";
4
+ import { buildFalUsage, takeBillableUnits } from "../utils/billing.js";
4
5
  const DURATION_FRAGMENT_BUILDERS = {
5
6
  "fal-ai/elevenlabs/music": (seconds) => ({ music_length_ms: seconds * 1e3 }),
6
7
  "fal-ai/stable-audio-25/text-to-audio": (seconds) => ({
@@ -60,13 +61,15 @@ class FalAudioAdapter extends BaseAudioAdapter {
60
61
  if (!audioUrl) {
61
62
  throw new Error("Audio URL not found in fal audio generation response");
62
63
  }
64
+ const usage = buildFalUsage(takeBillableUnits(response.requestId));
63
65
  return {
64
66
  id: response.requestId || this.generateId(),
65
67
  model: this.model,
66
68
  audio: {
67
69
  url: audioUrl,
68
70
  contentType: deriveAudioContentType(contentType, audioUrl)
69
- }
71
+ },
72
+ ...usage ? { usage } : {}
70
73
  };
71
74
  }
72
75
  }
@@ -1 +1 @@
1
- {"version":3,"file":"audio.js","sources":["../../../src/adapters/audio.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseAudioAdapter } from '@tanstack/ai/adapters'\nimport {\n configureFalClient,\n deriveAudioContentType,\n generateId as utilGenerateId,\n} from '../utils'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type {\n AudioGenerationOptions,\n AudioGenerationResult,\n} from '@tanstack/ai'\nimport type { FalClientConfig } from '../utils'\nimport type { FalModel, FalModelInput } from '../model-meta'\n\n/**\n * Provider options for audio generation, excluding fields TanStack AI handles.\n */\nexport type FalAudioProviderOptions<TModel extends string> = Omit<\n FalModelInput<TModel>,\n 'prompt'\n>\n\n/**\n * Fal audio models don't agree on the name for \"how long should this be.\"\n * Most accept seconds via `duration`, but a few use model-specific fields —\n * e.g. ElevenLabs wants milliseconds via `music_length_ms`, Stable Audio wants\n * `seconds_total`. Explicit per-model entries override the default; user\n * `modelOptions` still win over either.\n */\nconst DURATION_FRAGMENT_BUILDERS: Record<\n string,\n (seconds: number) => Record<string, unknown>\n> = {\n 'fal-ai/elevenlabs/music': (seconds) => ({ music_length_ms: seconds * 1000 }),\n 'fal-ai/stable-audio-25/text-to-audio': (seconds) => ({\n seconds_total: seconds,\n }),\n}\n\nfunction buildDurationFragment(\n model: string,\n duration: number | undefined,\n): Record<string, unknown> {\n if (duration == null) return {}\n const builder = DURATION_FRAGMENT_BUILDERS[model]\n return builder ? builder(duration) : { duration }\n}\n\n/**\n * fal.ai audio generation adapter.\n *\n * Supports fal.ai audio models like diffrhythm (music), sound effects, etc.\n *\n * @example\n * ```typescript\n * const adapter = falAudio('fal-ai/diffrhythm')\n * const result = await generateAudio({\n * adapter,\n * prompt: 'An upbeat electronic track with synths',\n * duration: 10,\n * })\n * ```\n */\nexport class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<\n TModel,\n FalAudioProviderOptions<TModel>\n> {\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super(model, {})\n configureFalClient(config)\n }\n\n async generateAudio(\n options: AudioGenerationOptions<FalAudioProviderOptions<TModel>>,\n ): Promise<AudioGenerationResult> {\n const { logger } = options\n logger.request(`activity=generateAudio provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n try {\n const input = this.buildInput(options)\n const result = await fal.subscribe(this.model, { input })\n return this.transformResponse(result)\n } catch (error) {\n logger.errors('fal.generateAudio fatal', {\n error,\n source: 'fal.generateAudio',\n })\n throw error\n }\n }\n\n private buildInput(\n options: AudioGenerationOptions<FalAudioProviderOptions<TModel>>,\n ): FalModelInput<TModel> {\n return {\n ...buildDurationFragment(this.model, options.duration),\n ...options.modelOptions,\n prompt: options.prompt,\n } as FalModelInput<TModel>\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n\n private transformResponse(\n response: Result<OutputType<TModel>>,\n ): AudioGenerationResult {\n const data = response.data as Record<string, unknown>\n\n // fal returns { audio: { url, content_type } } or { audio_url: string }\n let audioUrl: string | undefined\n let contentType: string | undefined\n\n if (\n data['audio'] &&\n typeof data['audio'] === 'object' &&\n 'url' in data['audio']\n ) {\n const audioObj = data['audio'] as { url: string; content_type?: string }\n audioUrl = audioObj.url\n contentType = audioObj.content_type\n } else if (typeof data.audio_url === 'string') {\n audioUrl = data.audio_url\n }\n\n if (!audioUrl) {\n throw new Error('Audio URL not found in fal audio generation response')\n }\n\n return {\n id: response.requestId || this.generateId(),\n model: this.model,\n audio: {\n url: audioUrl,\n contentType: deriveAudioContentType(contentType, audioUrl),\n },\n }\n }\n}\n\nexport function falAudio<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalAudioAdapter<TModel> {\n return new FalAudioAdapter(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;AA8BA,MAAM,6BAGF;AAAA,EACF,2BAA2B,CAAC,aAAa,EAAE,iBAAiB,UAAU,IAAA;AAAA,EACtE,wCAAwC,CAAC,aAAa;AAAA,IACpD,eAAe;AAAA,EAAA;AAEnB;AAEA,SAAS,sBACP,OACA,UACyB;AACzB,MAAI,YAAY,KAAM,QAAO,CAAA;AAC7B,QAAM,UAAU,2BAA2B,KAAK;AAChD,SAAO,UAAU,QAAQ,QAAQ,IAAI,EAAE,SAAA;AACzC;AAiBO,MAAM,wBAAiD,iBAG5D;AAAA,EACS,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,OAAO,EAAE;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,cACJ,SACgC;AAChC,UAAM,EAAE,WAAW;AACnB,WAAO,QAAQ,6CAA6C,KAAK,KAAK,IAAI;AAAA,MACxE,UAAU;AAAA,MACV,OAAO,KAAK;AAAA,IAAA,CACb;AACD,QAAI;AACF,YAAM,QAAQ,KAAK,WAAW,OAAO;AACrC,YAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO,EAAE,OAAO;AACxD,aAAO,KAAK,kBAAkB,MAAM;AAAA,IACtC,SAAS,OAAO;AACd,aAAO,OAAO,2BAA2B;AAAA,QACvC;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,WACN,SACuB;AACvB,WAAO;AAAA,MACL,GAAG,sBAAsB,KAAK,OAAO,QAAQ,QAAQ;AAAA,MACrD,GAAG,QAAQ;AAAA,MACX,QAAQ,QAAQ;AAAA,IAAA;AAAA,EAEpB;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AAAA,EAEQ,kBACN,UACuB;AACvB,UAAM,OAAO,SAAS;AAGtB,QAAI;AACJ,QAAI;AAEJ,QACE,KAAK,OAAO,KACZ,OAAO,KAAK,OAAO,MAAM,YACzB,SAAS,KAAK,OAAO,GACrB;AACA,YAAM,WAAW,KAAK,OAAO;AAC7B,iBAAW,SAAS;AACpB,oBAAc,SAAS;AAAA,IACzB,WAAW,OAAO,KAAK,cAAc,UAAU;AAC7C,iBAAW,KAAK;AAAA,IAClB;AAEA,QAAI,CAAC,UAAU;AACb,YAAM,IAAI,MAAM,sDAAsD;AAAA,IACxE;AAEA,WAAO;AAAA,MACL,IAAI,SAAS,aAAa,KAAK,WAAA;AAAA,MAC/B,OAAO,KAAK;AAAA,MACZ,OAAO;AAAA,QACL,KAAK;AAAA,QACL,aAAa,uBAAuB,aAAa,QAAQ;AAAA,MAAA;AAAA,IAC3D;AAAA,EAEJ;AACF;AAEO,SAAS,SACd,OACA,QACyB;AACzB,SAAO,IAAI,gBAAgB,OAAO,MAAM;AAC1C;"}
1
+ {"version":3,"file":"audio.js","sources":["../../../src/adapters/audio.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseAudioAdapter } from '@tanstack/ai/adapters'\nimport {\n buildFalUsage,\n configureFalClient,\n deriveAudioContentType,\n takeBillableUnits,\n generateId as utilGenerateId,\n} from '../utils'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type {\n AudioGenerationOptions,\n AudioGenerationResult,\n} from '@tanstack/ai'\nimport type { FalClientConfig } from '../utils'\nimport type { FalModel, FalModelInput } from '../model-meta'\n\n/**\n * Provider options for audio generation, excluding fields TanStack AI handles.\n */\nexport type FalAudioProviderOptions<TModel extends string> = Omit<\n FalModelInput<TModel>,\n 'prompt'\n>\n\n/**\n * Fal audio models don't agree on the name for \"how long should this be.\"\n * Most accept seconds via `duration`, but a few use model-specific fields —\n * e.g. ElevenLabs wants milliseconds via `music_length_ms`, Stable Audio wants\n * `seconds_total`. Explicit per-model entries override the default; user\n * `modelOptions` still win over either.\n */\nconst DURATION_FRAGMENT_BUILDERS: Record<\n string,\n (seconds: number) => Record<string, unknown>\n> = {\n 'fal-ai/elevenlabs/music': (seconds) => ({ music_length_ms: seconds * 1000 }),\n 'fal-ai/stable-audio-25/text-to-audio': (seconds) => ({\n seconds_total: seconds,\n }),\n}\n\nfunction buildDurationFragment(\n model: string,\n duration: number | undefined,\n): Record<string, unknown> {\n if (duration == null) return {}\n const builder = DURATION_FRAGMENT_BUILDERS[model]\n return builder ? builder(duration) : { duration }\n}\n\n/**\n * fal.ai audio generation adapter.\n *\n * Supports fal.ai audio models like diffrhythm (music), sound effects, etc.\n *\n * @example\n * ```typescript\n * const adapter = falAudio('fal-ai/diffrhythm')\n * const result = await generateAudio({\n * adapter,\n * prompt: 'An upbeat electronic track with synths',\n * duration: 10,\n * })\n * ```\n */\nexport class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<\n TModel,\n FalAudioProviderOptions<TModel>\n> {\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super(model, {})\n configureFalClient(config)\n }\n\n async generateAudio(\n options: AudioGenerationOptions<FalAudioProviderOptions<TModel>>,\n ): Promise<AudioGenerationResult> {\n const { logger } = options\n logger.request(`activity=generateAudio provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n try {\n const input = this.buildInput(options)\n const result = await fal.subscribe(this.model, { input })\n return this.transformResponse(result)\n } catch (error) {\n logger.errors('fal.generateAudio fatal', {\n error,\n source: 'fal.generateAudio',\n })\n throw error\n }\n }\n\n private buildInput(\n options: AudioGenerationOptions<FalAudioProviderOptions<TModel>>,\n ): FalModelInput<TModel> {\n return {\n ...buildDurationFragment(this.model, options.duration),\n ...options.modelOptions,\n prompt: options.prompt,\n } as FalModelInput<TModel>\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n\n private transformResponse(\n response: Result<OutputType<TModel>>,\n ): AudioGenerationResult {\n const data = response.data as Record<string, unknown>\n\n // fal returns { audio: { url, content_type } } or { audio_url: string }\n let audioUrl: string | undefined\n let contentType: string | undefined\n\n if (\n data['audio'] &&\n typeof data['audio'] === 'object' &&\n 'url' in data['audio']\n ) {\n const audioObj = data['audio'] as { url: string; content_type?: string }\n audioUrl = audioObj.url\n contentType = audioObj.content_type\n } else if (typeof data.audio_url === 'string') {\n audioUrl = data.audio_url\n }\n\n if (!audioUrl) {\n throw new Error('Audio URL not found in fal audio generation response')\n }\n\n const usage = buildFalUsage(takeBillableUnits(response.requestId))\n\n return {\n id: response.requestId || this.generateId(),\n model: this.model,\n audio: {\n url: audioUrl,\n contentType: deriveAudioContentType(contentType, audioUrl),\n },\n ...(usage ? { usage } : {}),\n }\n }\n}\n\nexport function falAudio<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalAudioAdapter<TModel> {\n return new FalAudioAdapter(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;;AAgCA,MAAM,6BAGF;AAAA,EACF,2BAA2B,CAAC,aAAa,EAAE,iBAAiB,UAAU,IAAA;AAAA,EACtE,wCAAwC,CAAC,aAAa;AAAA,IACpD,eAAe;AAAA,EAAA;AAEnB;AAEA,SAAS,sBACP,OACA,UACyB;AACzB,MAAI,YAAY,KAAM,QAAO,CAAA;AAC7B,QAAM,UAAU,2BAA2B,KAAK;AAChD,SAAO,UAAU,QAAQ,QAAQ,IAAI,EAAE,SAAA;AACzC;AAiBO,MAAM,wBAAiD,iBAG5D;AAAA,EACS,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,OAAO,EAAE;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,cACJ,SACgC;AAChC,UAAM,EAAE,WAAW;AACnB,WAAO,QAAQ,6CAA6C,KAAK,KAAK,IAAI;AAAA,MACxE,UAAU;AAAA,MACV,OAAO,KAAK;AAAA,IAAA,CACb;AACD,QAAI;AACF,YAAM,QAAQ,KAAK,WAAW,OAAO;AACrC,YAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO,EAAE,OAAO;AACxD,aAAO,KAAK,kBAAkB,MAAM;AAAA,IACtC,SAAS,OAAO;AACd,aAAO,OAAO,2BAA2B;AAAA,QACvC;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,WACN,SACuB;AACvB,WAAO;AAAA,MACL,GAAG,sBAAsB,KAAK,OAAO,QAAQ,QAAQ;AAAA,MACrD,GAAG,QAAQ;AAAA,MACX,QAAQ,QAAQ;AAAA,IAAA;AAAA,EAEpB;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AAAA,EAEQ,kBACN,UACuB;AACvB,UAAM,OAAO,SAAS;AAGtB,QAAI;AACJ,QAAI;AAEJ,QACE,KAAK,OAAO,KACZ,OAAO,KAAK,OAAO,MAAM,YACzB,SAAS,KAAK,OAAO,GACrB;AACA,YAAM,WAAW,KAAK,OAAO;AAC7B,iBAAW,SAAS;AACpB,oBAAc,SAAS;AAAA,IACzB,WAAW,OAAO,KAAK,cAAc,UAAU;AAC7C,iBAAW,KAAK;AAAA,IAClB;AAEA,QAAI,CAAC,UAAU;AACb,YAAM,IAAI,MAAM,sDAAsD;AAAA,IACxE;AAEA,UAAM,QAAQ,cAAc,kBAAkB,SAAS,SAAS,CAAC;AAEjE,WAAO;AAAA,MACL,IAAI,SAAS,aAAa,KAAK,WAAA;AAAA,MAC/B,OAAO,KAAK;AAAA,MACZ,OAAO;AAAA,QACL,KAAK;AAAA,QACL,aAAa,uBAAuB,aAAa,QAAQ;AAAA,MAAA;AAAA,MAE3D,GAAI,QAAQ,EAAE,UAAU,CAAA;AAAA,IAAC;AAAA,EAE7B;AACF;AAEO,SAAS,SACd,OACA,QACyB;AACzB,SAAO,IAAI,gBAAgB,OAAO,MAAM;AAC1C;"}
@@ -1,6 +1,7 @@
1
1
  import { fal } from "@fal-ai/client";
2
2
  import { BaseImageAdapter } from "@tanstack/ai/adapters";
3
3
  import { configureFalClient, generateId } from "../utils/client.js";
4
+ import { buildFalUsage, takeBillableUnits } from "../utils/billing.js";
4
5
  import { mapSizeToFalFormat } from "../image/image-provider-options.js";
5
6
  class FalImageAdapter extends BaseImageAdapter {
6
7
  kind = "image";
@@ -55,10 +56,12 @@ class FalImageAdapter extends BaseImageAdapter {
55
56
  "Unexpected fal image response shape. Expected images[] or image{}. Got keys: " + Object.keys(data).join(",")
56
57
  );
57
58
  }
59
+ const usage = buildFalUsage(takeBillableUnits(response.requestId));
58
60
  return {
59
61
  id: response.requestId || this.generateId(),
60
62
  model: this.model,
61
- images
63
+ images,
64
+ ...usage ? { usage } : {}
62
65
  };
63
66
  }
64
67
  parseImage(img) {
@@ -1 +1 @@
1
- {"version":3,"file":"image.js","sources":["../../../src/adapters/image.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseImageAdapter } from '@tanstack/ai/adapters'\nimport { configureFalClient, generateId as utilGenerateId } from '../utils'\nimport { mapSizeToFalFormat } from '../image/image-provider-options'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type { FalClientConfig } from '../utils'\nimport type {\n GeneratedImage,\n ImageGenerationOptions,\n ImageGenerationResult,\n} from '@tanstack/ai'\nimport type {\n FalImageProviderOptions,\n FalModel,\n FalModelImageSize,\n FalModelInput,\n} from '../model-meta'\n\n/**\n * fal.ai image generation adapter with full type inference.\n *\n * Uses fal.ai's comprehensive type system to provide autocomplete\n *\n * and type safety for all 600+ supported models.\n *\n * @example\n * ```typescript\n * const adapter = falImage('fal-ai/flux/dev')\n * const result = await adapter.generateImages({\n * model: 'fal-ai/flux/dev',\n * prompt: 'a cat',\n * modelOptions: {\n * num_inference_steps: 28, // Type-safe! Autocomplete works\n * guidance_scale: 3.5,\n * },\n * })\n * ```\n */\nexport class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<\n TModel,\n FalImageProviderOptions<TModel>,\n Record<TModel, FalImageProviderOptions<TModel>>,\n Record<TModel, FalModelImageSize<TModel>>\n> {\n override readonly kind = 'image' as const\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super(model, {})\n configureFalClient(config)\n }\n\n async generateImages(\n options: ImageGenerationOptions<\n FalImageProviderOptions<TModel>,\n FalModelImageSize<TModel>\n >,\n ): Promise<ImageGenerationResult> {\n const { logger } = options\n\n logger.request(`activity=generateImage provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n\n try {\n const input = this.buildInput(options)\n const result = await fal.subscribe(this.model, { input })\n return this.transformResponse(result)\n } catch (error) {\n logger.errors('fal.generateImage fatal', {\n error,\n source: 'fal.generateImage',\n })\n throw error\n }\n }\n\n private buildInput(\n options: ImageGenerationOptions<\n FalImageProviderOptions<TModel>,\n FalModelImageSize<TModel>\n >,\n ): FalModelInput<TModel> {\n const sizeParams = mapSizeToFalFormat(options.size)\n const input = {\n ...options.modelOptions,\n ...sizeParams,\n prompt: options.prompt,\n num_images: options.numberOfImages,\n } as FalModelInput<TModel>\n return input\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n\n private transformResponse(\n response: Result<OutputType<TModel>>,\n ): ImageGenerationResult {\n const images: Array<GeneratedImage> = []\n const data = response.data\n\n // Handle array of images (most models return { images: [...] })\n if ('images' in data && Array.isArray(data.images)) {\n for (const img of data.images) {\n images.push(this.parseImage(img))\n }\n }\n // Handle single image response (some models return { image: {...} })\n else if ('image' in data && data.image && typeof data.image === 'object') {\n images.push(this.parseImage(data.image))\n }\n\n if (images.length === 0) {\n throw new Error(\n 'Unexpected fal image response shape. Expected images[] or image{}. Got keys: ' +\n Object.keys(data).join(','),\n )\n }\n\n return {\n id: response.requestId || this.generateId(),\n model: this.model,\n images,\n }\n }\n\n private parseImage(img: unknown): GeneratedImage {\n let url: string\n if (typeof img === 'string') {\n url = img\n } else if (\n img &&\n typeof img === 'object' &&\n 'url' in img &&\n typeof img.url === 'string'\n ) {\n url = (img as { url: string }).url\n } else {\n throw new Error(\n `Invalid image payload from fal response: expected string or { url: string }, received ${\n img === null ? 'null' : typeof img\n }`,\n )\n }\n\n if (url.startsWith('data:')) {\n const base64Match = url.match(/^data:image\\/[^;]+;base64,(.+)$/)\n if (base64Match && base64Match[1]) {\n return { b64Json: base64Match[1] }\n }\n }\n return { url }\n }\n}\n\nexport function createFalImage<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalImageAdapter<TModel> {\n return new FalImageAdapter(model, config)\n}\n\nexport function falImage<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalImageAdapter<TModel> {\n return createFalImage(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;;AAsCO,MAAM,wBAAiD,iBAK5D;AAAA,EACkB,OAAO;AAAA,EAChB,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,OAAO,EAAE;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,eACJ,SAIgC;AAChC,UAAM,EAAE,WAAW;AAEnB,WAAO,QAAQ,6CAA6C,KAAK,KAAK,IAAI;AAAA,MACxE,UAAU;AAAA,MACV,OAAO,KAAK;AAAA,IAAA,CACb;AAED,QAAI;AACF,YAAM,QAAQ,KAAK,WAAW,OAAO;AACrC,YAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO,EAAE,OAAO;AACxD,aAAO,KAAK,kBAAkB,MAAM;AAAA,IACtC,SAAS,OAAO;AACd,aAAO,OAAO,2BAA2B;AAAA,QACvC;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,WACN,SAIuB;AACvB,UAAM,aAAa,mBAAmB,QAAQ,IAAI;AAClD,UAAM,QAAQ;AAAA,MACZ,GAAG,QAAQ;AAAA,MACX,GAAG;AAAA,MACH,QAAQ,QAAQ;AAAA,MAChB,YAAY,QAAQ;AAAA,IAAA;AAEtB,WAAO;AAAA,EACT;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AAAA,EAEQ,kBACN,UACuB;AACvB,UAAM,SAAgC,CAAA;AACtC,UAAM,OAAO,SAAS;AAGtB,QAAI,YAAY,QAAQ,MAAM,QAAQ,KAAK,MAAM,GAAG;AAClD,iBAAW,OAAO,KAAK,QAAQ;AAC7B,eAAO,KAAK,KAAK,WAAW,GAAG,CAAC;AAAA,MAClC;AAAA,IACF,WAES,WAAW,QAAQ,KAAK,SAAS,OAAO,KAAK,UAAU,UAAU;AACxE,aAAO,KAAK,KAAK,WAAW,KAAK,KAAK,CAAC;AAAA,IACzC;AAEA,QAAI,OAAO,WAAW,GAAG;AACvB,YAAM,IAAI;AAAA,QACR,kFACE,OAAO,KAAK,IAAI,EAAE,KAAK,GAAG;AAAA,MAAA;AAAA,IAEhC;AAEA,WAAO;AAAA,MACL,IAAI,SAAS,aAAa,KAAK,WAAA;AAAA,MAC/B,OAAO,KAAK;AAAA,MACZ;AAAA,IAAA;AAAA,EAEJ;AAAA,EAEQ,WAAW,KAA8B;AAC/C,QAAI;AACJ,QAAI,OAAO,QAAQ,UAAU;AAC3B,YAAM;AAAA,IACR,WACE,OACA,OAAO,QAAQ,YACf,SAAS,OACT,OAAO,IAAI,QAAQ,UACnB;AACA,YAAO,IAAwB;AAAA,IACjC,OAAO;AACL,YAAM,IAAI;AAAA,QACR,yFACE,QAAQ,OAAO,SAAS,OAAO,GACjC;AAAA,MAAA;AAAA,IAEJ;AAEA,QAAI,IAAI,WAAW,OAAO,GAAG;AAC3B,YAAM,cAAc,IAAI,MAAM,iCAAiC;AAC/D,UAAI,eAAe,YAAY,CAAC,GAAG;AACjC,eAAO,EAAE,SAAS,YAAY,CAAC,EAAA;AAAA,MACjC;AAAA,IACF;AACA,WAAO,EAAE,IAAA;AAAA,EACX;AACF;AAEO,SAAS,eACd,OACA,QACyB;AACzB,SAAO,IAAI,gBAAgB,OAAO,MAAM;AAC1C;AAEO,SAAS,SACd,OACA,QACyB;AACzB,SAAO,eAAe,OAAO,MAAM;AACrC;"}
1
+ {"version":3,"file":"image.js","sources":["../../../src/adapters/image.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseImageAdapter } from '@tanstack/ai/adapters'\nimport {\n buildFalUsage,\n configureFalClient,\n takeBillableUnits,\n generateId as utilGenerateId,\n} from '../utils'\nimport { mapSizeToFalFormat } from '../image/image-provider-options'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type { FalClientConfig } from '../utils'\nimport type {\n GeneratedImage,\n ImageGenerationOptions,\n ImageGenerationResult,\n} from '@tanstack/ai'\nimport type {\n FalImageProviderOptions,\n FalModel,\n FalModelImageSize,\n FalModelInput,\n} from '../model-meta'\n\n/**\n * fal.ai image generation adapter with full type inference.\n *\n * Uses fal.ai's comprehensive type system to provide autocomplete\n *\n * and type safety for all 600+ supported models.\n *\n * @example\n * ```typescript\n * const adapter = falImage('fal-ai/flux/dev')\n * const result = await adapter.generateImages({\n * model: 'fal-ai/flux/dev',\n * prompt: 'a cat',\n * modelOptions: {\n * num_inference_steps: 28, // Type-safe! Autocomplete works\n * guidance_scale: 3.5,\n * },\n * })\n * ```\n */\nexport class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<\n TModel,\n FalImageProviderOptions<TModel>,\n Record<TModel, FalImageProviderOptions<TModel>>,\n Record<TModel, FalModelImageSize<TModel>>\n> {\n override readonly kind = 'image' as const\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super(model, {})\n configureFalClient(config)\n }\n\n async generateImages(\n options: ImageGenerationOptions<\n FalImageProviderOptions<TModel>,\n FalModelImageSize<TModel>\n >,\n ): Promise<ImageGenerationResult> {\n const { logger } = options\n\n logger.request(`activity=generateImage provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n\n try {\n const input = this.buildInput(options)\n const result = await fal.subscribe(this.model, { input })\n return this.transformResponse(result)\n } catch (error) {\n logger.errors('fal.generateImage fatal', {\n error,\n source: 'fal.generateImage',\n })\n throw error\n }\n }\n\n private buildInput(\n options: ImageGenerationOptions<\n FalImageProviderOptions<TModel>,\n FalModelImageSize<TModel>\n >,\n ): FalModelInput<TModel> {\n const sizeParams = mapSizeToFalFormat(options.size)\n const input = {\n ...options.modelOptions,\n ...sizeParams,\n prompt: options.prompt,\n num_images: options.numberOfImages,\n } as FalModelInput<TModel>\n return input\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n\n private transformResponse(\n response: Result<OutputType<TModel>>,\n ): ImageGenerationResult {\n const images: Array<GeneratedImage> = []\n const data = response.data\n\n // Handle array of images (most models return { images: [...] })\n if ('images' in data && Array.isArray(data.images)) {\n for (const img of data.images) {\n images.push(this.parseImage(img))\n }\n }\n // Handle single image response (some models return { image: {...} })\n else if ('image' in data && data.image && typeof data.image === 'object') {\n images.push(this.parseImage(data.image))\n }\n\n if (images.length === 0) {\n throw new Error(\n 'Unexpected fal image response shape. Expected images[] or image{}. Got keys: ' +\n Object.keys(data).join(','),\n )\n }\n\n const usage = buildFalUsage(takeBillableUnits(response.requestId))\n\n return {\n id: response.requestId || this.generateId(),\n model: this.model,\n images,\n ...(usage ? { usage } : {}),\n }\n }\n\n private parseImage(img: unknown): GeneratedImage {\n let url: string\n if (typeof img === 'string') {\n url = img\n } else if (\n img &&\n typeof img === 'object' &&\n 'url' in img &&\n typeof img.url === 'string'\n ) {\n url = (img as { url: string }).url\n } else {\n throw new Error(\n `Invalid image payload from fal response: expected string or { url: string }, received ${\n img === null ? 'null' : typeof img\n }`,\n )\n }\n\n if (url.startsWith('data:')) {\n const base64Match = url.match(/^data:image\\/[^;]+;base64,(.+)$/)\n if (base64Match && base64Match[1]) {\n return { b64Json: base64Match[1] }\n }\n }\n return { url }\n }\n}\n\nexport function createFalImage<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalImageAdapter<TModel> {\n return new FalImageAdapter(model, config)\n}\n\nexport function falImage<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalImageAdapter<TModel> {\n return createFalImage(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;;;AA2CO,MAAM,wBAAiD,iBAK5D;AAAA,EACkB,OAAO;AAAA,EAChB,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,OAAO,EAAE;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,eACJ,SAIgC;AAChC,UAAM,EAAE,WAAW;AAEnB,WAAO,QAAQ,6CAA6C,KAAK,KAAK,IAAI;AAAA,MACxE,UAAU;AAAA,MACV,OAAO,KAAK;AAAA,IAAA,CACb;AAED,QAAI;AACF,YAAM,QAAQ,KAAK,WAAW,OAAO;AACrC,YAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO,EAAE,OAAO;AACxD,aAAO,KAAK,kBAAkB,MAAM;AAAA,IACtC,SAAS,OAAO;AACd,aAAO,OAAO,2BAA2B;AAAA,QACvC;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,WACN,SAIuB;AACvB,UAAM,aAAa,mBAAmB,QAAQ,IAAI;AAClD,UAAM,QAAQ;AAAA,MACZ,GAAG,QAAQ;AAAA,MACX,GAAG;AAAA,MACH,QAAQ,QAAQ;AAAA,MAChB,YAAY,QAAQ;AAAA,IAAA;AAEtB,WAAO;AAAA,EACT;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AAAA,EAEQ,kBACN,UACuB;AACvB,UAAM,SAAgC,CAAA;AACtC,UAAM,OAAO,SAAS;AAGtB,QAAI,YAAY,QAAQ,MAAM,QAAQ,KAAK,MAAM,GAAG;AAClD,iBAAW,OAAO,KAAK,QAAQ;AAC7B,eAAO,KAAK,KAAK,WAAW,GAAG,CAAC;AAAA,MAClC;AAAA,IACF,WAES,WAAW,QAAQ,KAAK,SAAS,OAAO,KAAK,UAAU,UAAU;AACxE,aAAO,KAAK,KAAK,WAAW,KAAK,KAAK,CAAC;AAAA,IACzC;AAEA,QAAI,OAAO,WAAW,GAAG;AACvB,YAAM,IAAI;AAAA,QACR,kFACE,OAAO,KAAK,IAAI,EAAE,KAAK,GAAG;AAAA,MAAA;AAAA,IAEhC;AAEA,UAAM,QAAQ,cAAc,kBAAkB,SAAS,SAAS,CAAC;AAEjE,WAAO;AAAA,MACL,IAAI,SAAS,aAAa,KAAK,WAAA;AAAA,MAC/B,OAAO,KAAK;AAAA,MACZ;AAAA,MACA,GAAI,QAAQ,EAAE,UAAU,CAAA;AAAA,IAAC;AAAA,EAE7B;AAAA,EAEQ,WAAW,KAA8B;AAC/C,QAAI;AACJ,QAAI,OAAO,QAAQ,UAAU;AAC3B,YAAM;AAAA,IACR,WACE,OACA,OAAO,QAAQ,YACf,SAAS,OACT,OAAO,IAAI,QAAQ,UACnB;AACA,YAAO,IAAwB;AAAA,IACjC,OAAO;AACL,YAAM,IAAI;AAAA,QACR,yFACE,QAAQ,OAAO,SAAS,OAAO,GACjC;AAAA,MAAA;AAAA,IAEJ;AAEA,QAAI,IAAI,WAAW,OAAO,GAAG;AAC3B,YAAM,cAAc,IAAI,MAAM,iCAAiC;AAC/D,UAAI,eAAe,YAAY,CAAC,GAAG;AACjC,eAAO,EAAE,SAAS,YAAY,CAAC,EAAA;AAAA,MACjC;AAAA,IACF;AACA,WAAO,EAAE,IAAA;AAAA,EACX;AACF;AAEO,SAAS,eACd,OACA,QACyB;AACzB,SAAO,IAAI,gBAAgB,OAAO,MAAM;AAC1C;AAEO,SAAS,SACd,OACA,QACyB;AACzB,SAAO,eAAe,OAAO,MAAM;AACrC;"}
@@ -1,6 +1,7 @@
1
1
  import { fal } from "@fal-ai/client";
2
2
  import { BaseTTSAdapter } from "@tanstack/ai/adapters";
3
3
  import { configureFalClient, generateId, arrayBufferToBase64, extractUrlExtension } from "../utils/client.js";
4
+ import { buildFalUsage, takeBillableUnits } from "../utils/billing.js";
4
5
  class FalSpeechAdapter extends BaseTTSAdapter {
5
6
  name = "fal";
6
7
  constructor(model, config) {
@@ -65,12 +66,14 @@ class FalSpeechAdapter extends BaseTTSAdapter {
65
66
  const safeUrlExtension = extractUrlExtension(audioUrl);
66
67
  const rawFormat = safeUrlExtension || contentTypeMime?.split("/")[1] || "wav";
67
68
  const format = rawFormat === "mpeg" ? "mp3" : rawFormat;
69
+ const usage = buildFalUsage(takeBillableUnits(response.requestId));
68
70
  return {
69
71
  id: response.requestId || this.generateId(),
70
72
  model: this.model,
71
73
  audio: base64,
72
74
  format,
73
- contentType: contentTypeMime || `audio/${format}`
75
+ contentType: contentTypeMime || `audio/${format}`,
76
+ ...usage ? { usage } : {}
74
77
  };
75
78
  }
76
79
  }
@@ -1 +1 @@
1
- {"version":3,"file":"speech.js","sources":["../../../src/adapters/speech.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseTTSAdapter } from '@tanstack/ai/adapters'\nimport {\n arrayBufferToBase64,\n configureFalClient,\n extractUrlExtension,\n generateId as utilGenerateId,\n} from '../utils'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type { TTSOptions, TTSResult } from '@tanstack/ai'\nimport type { FalClientConfig } from '../utils'\nimport type { FalModel, FalModelInput } from '../model-meta'\n\n/**\n * Provider options for TTS, excluding fields TanStack AI handles.\n */\nexport type FalSpeechProviderOptions<TModel extends string> = Omit<\n FalModelInput<TModel>,\n 'prompt' | 'text'\n>\n\n/**\n * fal.ai text-to-speech adapter.\n *\n * Supports fal.ai TTS models like kokoro, elevenlabs, etc.\n *\n * @example\n * ```typescript\n * const adapter = falSpeech('fal-ai/kokoro/american-english')\n * const result = await generateSpeech({\n * adapter,\n * text: 'Hello, world!',\n * voice: 'af_heart',\n * })\n * ```\n */\nexport class FalSpeechAdapter<TModel extends FalModel> extends BaseTTSAdapter<\n TModel,\n FalSpeechProviderOptions<TModel>\n> {\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super(model, {})\n configureFalClient(config)\n }\n\n async generateSpeech(\n options: TTSOptions<FalSpeechProviderOptions<TModel>>,\n ): Promise<TTSResult> {\n const { logger } = options\n logger.request(`activity=generateSpeech provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n try {\n const input = this.buildInput(options)\n const result = await fal.subscribe(this.model, { input })\n return await this.transformResponse(result)\n } catch (error) {\n logger.errors('fal.generateSpeech fatal', {\n error,\n source: 'fal.generateSpeech',\n })\n throw error\n }\n }\n\n private buildInput(\n options: TTSOptions<FalSpeechProviderOptions<TModel>>,\n ): FalModelInput<TModel> {\n const input = {\n ...options.modelOptions,\n // Map text to both prompt and text fields (different models use different fields)\n prompt: options.text,\n text: options.text,\n ...(options.voice ? { voice: options.voice } : {}),\n ...(options.speed ? { speed: options.speed } : {}),\n } as FalModelInput<TModel>\n return input\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n\n private async transformResponse(\n response: Result<OutputType<TModel>>,\n ): Promise<TTSResult> {\n const data = response.data as Record<string, unknown>\n\n // fal returns { audio: { url, content_type } } or { audio_url: string }\n let audioUrl: string | undefined\n let contentType: string | undefined\n\n if (\n data['audio'] &&\n typeof data['audio'] === 'object' &&\n 'url' in data['audio']\n ) {\n const audioObj = data['audio'] as { url: string; content_type?: string }\n audioUrl = audioObj.url\n contentType = audioObj.content_type\n } else if (typeof data.audio_url === 'string') {\n audioUrl = data.audio_url\n }\n\n if (!audioUrl) {\n throw new Error('Audio URL not found in fal TTS response')\n }\n\n // Fetch the audio and convert to base64 to match TTSResult contract.\n // Using a chunked helper here — spreading Uint8Array into btoa exceeds\n // V8's argument limit (~65k) for any realistic TTS clip.\n const audioResponse = await fetch(audioUrl)\n if (!audioResponse.ok) {\n throw new Error(\n `Failed to fetch audio from ${audioUrl}: ${audioResponse.status} ${audioResponse.statusText}`,\n )\n }\n const arrayBuffer = await audioResponse.arrayBuffer()\n const base64 = arrayBufferToBase64(arrayBuffer)\n\n // Strip parameters like `; charset=...` from contentType, and only use\n // the URL extension as a fallback when it looks like a real extension.\n const contentTypeMime = contentType?.split(';')[0]?.trim()\n const safeUrlExtension = extractUrlExtension(audioUrl)\n // Prefer URL-derived extension when available (more canonical for file\n // consumers), otherwise derive from the content-type mime subtype, then\n // fall back to `wav`. Normalize `mpeg` → `mp3` so the format field is a\n // usable file extension rather than the IANA subtype.\n const rawFormat =\n safeUrlExtension || contentTypeMime?.split('/')[1] || 'wav'\n const format = rawFormat === 'mpeg' ? 'mp3' : rawFormat\n\n return {\n id: response.requestId || this.generateId(),\n model: this.model,\n audio: base64,\n format,\n contentType: contentTypeMime || `audio/${format}`,\n }\n }\n}\n\nexport function falSpeech<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalSpeechAdapter<TModel> {\n return new FalSpeechAdapter(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;AAoCO,MAAM,yBAAkD,eAG7D;AAAA,EACS,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,OAAO,EAAE;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,eACJ,SACoB;AACpB,UAAM,EAAE,WAAW;AACnB,WAAO,QAAQ,8CAA8C,KAAK,KAAK,IAAI;AAAA,MACzE,UAAU;AAAA,MACV,OAAO,KAAK;AAAA,IAAA,CACb;AACD,QAAI;AACF,YAAM,QAAQ,KAAK,WAAW,OAAO;AACrC,YAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO,EAAE,OAAO;AACxD,aAAO,MAAM,KAAK,kBAAkB,MAAM;AAAA,IAC5C,SAAS,OAAO;AACd,aAAO,OAAO,4BAA4B;AAAA,QACxC;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,WACN,SACuB;AACvB,UAAM,QAAQ;AAAA,MACZ,GAAG,QAAQ;AAAA;AAAA,MAEX,QAAQ,QAAQ;AAAA,MAChB,MAAM,QAAQ;AAAA,MACd,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAA,IAAU,CAAA;AAAA,MAC/C,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAA,IAAU,CAAA;AAAA,IAAC;AAElD,WAAO;AAAA,EACT;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AAAA,EAEA,MAAc,kBACZ,UACoB;AACpB,UAAM,OAAO,SAAS;AAGtB,QAAI;AACJ,QAAI;AAEJ,QACE,KAAK,OAAO,KACZ,OAAO,KAAK,OAAO,MAAM,YACzB,SAAS,KAAK,OAAO,GACrB;AACA,YAAM,WAAW,KAAK,OAAO;AAC7B,iBAAW,SAAS;AACpB,oBAAc,SAAS;AAAA,IACzB,WAAW,OAAO,KAAK,cAAc,UAAU;AAC7C,iBAAW,KAAK;AAAA,IAClB;AAEA,QAAI,CAAC,UAAU;AACb,YAAM,IAAI,MAAM,yCAAyC;AAAA,IAC3D;AAKA,UAAM,gBAAgB,MAAM,MAAM,QAAQ;AAC1C,QAAI,CAAC,cAAc,IAAI;AACrB,YAAM,IAAI;AAAA,QACR,8BAA8B,QAAQ,KAAK,cAAc,MAAM,IAAI,cAAc,UAAU;AAAA,MAAA;AAAA,IAE/F;AACA,UAAM,cAAc,MAAM,cAAc,YAAA;AACxC,UAAM,SAAS,oBAAoB,WAAW;AAI9C,UAAM,kBAAkB,aAAa,MAAM,GAAG,EAAE,CAAC,GAAG,KAAA;AACpD,UAAM,mBAAmB,oBAAoB,QAAQ;AAKrD,UAAM,YACJ,oBAAoB,iBAAiB,MAAM,GAAG,EAAE,CAAC,KAAK;AACxD,UAAM,SAAS,cAAc,SAAS,QAAQ;AAE9C,WAAO;AAAA,MACL,IAAI,SAAS,aAAa,KAAK,WAAA;AAAA,MAC/B,OAAO,KAAK;AAAA,MACZ,OAAO;AAAA,MACP;AAAA,MACA,aAAa,mBAAmB,SAAS,MAAM;AAAA,IAAA;AAAA,EAEnD;AACF;AAEO,SAAS,UACd,OACA,QAC0B;AAC1B,SAAO,IAAI,iBAAiB,OAAO,MAAM;AAC3C;"}
1
+ {"version":3,"file":"speech.js","sources":["../../../src/adapters/speech.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseTTSAdapter } from '@tanstack/ai/adapters'\nimport {\n arrayBufferToBase64,\n buildFalUsage,\n configureFalClient,\n extractUrlExtension,\n takeBillableUnits,\n generateId as utilGenerateId,\n} from '../utils'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type { TTSOptions, TTSResult } from '@tanstack/ai'\nimport type { FalClientConfig } from '../utils'\nimport type { FalModel, FalModelInput } from '../model-meta'\n\n/**\n * Provider options for TTS, excluding fields TanStack AI handles.\n */\nexport type FalSpeechProviderOptions<TModel extends string> = Omit<\n FalModelInput<TModel>,\n 'prompt' | 'text'\n>\n\n/**\n * fal.ai text-to-speech adapter.\n *\n * Supports fal.ai TTS models like kokoro, elevenlabs, etc.\n *\n * @example\n * ```typescript\n * const adapter = falSpeech('fal-ai/kokoro/american-english')\n * const result = await generateSpeech({\n * adapter,\n * text: 'Hello, world!',\n * voice: 'af_heart',\n * })\n * ```\n */\nexport class FalSpeechAdapter<TModel extends FalModel> extends BaseTTSAdapter<\n TModel,\n FalSpeechProviderOptions<TModel>\n> {\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super(model, {})\n configureFalClient(config)\n }\n\n async generateSpeech(\n options: TTSOptions<FalSpeechProviderOptions<TModel>>,\n ): Promise<TTSResult> {\n const { logger } = options\n logger.request(`activity=generateSpeech provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n try {\n const input = this.buildInput(options)\n const result = await fal.subscribe(this.model, { input })\n return await this.transformResponse(result)\n } catch (error) {\n logger.errors('fal.generateSpeech fatal', {\n error,\n source: 'fal.generateSpeech',\n })\n throw error\n }\n }\n\n private buildInput(\n options: TTSOptions<FalSpeechProviderOptions<TModel>>,\n ): FalModelInput<TModel> {\n const input = {\n ...options.modelOptions,\n // Map text to both prompt and text fields (different models use different fields)\n prompt: options.text,\n text: options.text,\n ...(options.voice ? { voice: options.voice } : {}),\n ...(options.speed ? { speed: options.speed } : {}),\n } as FalModelInput<TModel>\n return input\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n\n private async transformResponse(\n response: Result<OutputType<TModel>>,\n ): Promise<TTSResult> {\n const data = response.data as Record<string, unknown>\n\n // fal returns { audio: { url, content_type } } or { audio_url: string }\n let audioUrl: string | undefined\n let contentType: string | undefined\n\n if (\n data['audio'] &&\n typeof data['audio'] === 'object' &&\n 'url' in data['audio']\n ) {\n const audioObj = data['audio'] as { url: string; content_type?: string }\n audioUrl = audioObj.url\n contentType = audioObj.content_type\n } else if (typeof data.audio_url === 'string') {\n audioUrl = data.audio_url\n }\n\n if (!audioUrl) {\n throw new Error('Audio URL not found in fal TTS response')\n }\n\n // Fetch the audio and convert to base64 to match TTSResult contract.\n // Using a chunked helper here — spreading Uint8Array into btoa exceeds\n // V8's argument limit (~65k) for any realistic TTS clip.\n const audioResponse = await fetch(audioUrl)\n if (!audioResponse.ok) {\n throw new Error(\n `Failed to fetch audio from ${audioUrl}: ${audioResponse.status} ${audioResponse.statusText}`,\n )\n }\n const arrayBuffer = await audioResponse.arrayBuffer()\n const base64 = arrayBufferToBase64(arrayBuffer)\n\n // Strip parameters like `; charset=...` from contentType, and only use\n // the URL extension as a fallback when it looks like a real extension.\n const contentTypeMime = contentType?.split(';')[0]?.trim()\n const safeUrlExtension = extractUrlExtension(audioUrl)\n // Prefer URL-derived extension when available (more canonical for file\n // consumers), otherwise derive from the content-type mime subtype, then\n // fall back to `wav`. Normalize `mpeg` → `mp3` so the format field is a\n // usable file extension rather than the IANA subtype.\n const rawFormat =\n safeUrlExtension || contentTypeMime?.split('/')[1] || 'wav'\n const format = rawFormat === 'mpeg' ? 'mp3' : rawFormat\n\n const usage = buildFalUsage(takeBillableUnits(response.requestId))\n\n return {\n id: response.requestId || this.generateId(),\n model: this.model,\n audio: base64,\n format,\n contentType: contentTypeMime || `audio/${format}`,\n ...(usage ? { usage } : {}),\n }\n }\n}\n\nexport function falSpeech<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalSpeechAdapter<TModel> {\n return new FalSpeechAdapter(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;;AAsCO,MAAM,yBAAkD,eAG7D;AAAA,EACS,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,OAAO,EAAE;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,eACJ,SACoB;AACpB,UAAM,EAAE,WAAW;AACnB,WAAO,QAAQ,8CAA8C,KAAK,KAAK,IAAI;AAAA,MACzE,UAAU;AAAA,MACV,OAAO,KAAK;AAAA,IAAA,CACb;AACD,QAAI;AACF,YAAM,QAAQ,KAAK,WAAW,OAAO;AACrC,YAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO,EAAE,OAAO;AACxD,aAAO,MAAM,KAAK,kBAAkB,MAAM;AAAA,IAC5C,SAAS,OAAO;AACd,aAAO,OAAO,4BAA4B;AAAA,QACxC;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,WACN,SACuB;AACvB,UAAM,QAAQ;AAAA,MACZ,GAAG,QAAQ;AAAA;AAAA,MAEX,QAAQ,QAAQ;AAAA,MAChB,MAAM,QAAQ;AAAA,MACd,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAA,IAAU,CAAA;AAAA,MAC/C,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAA,IAAU,CAAA;AAAA,IAAC;AAElD,WAAO;AAAA,EACT;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AAAA,EAEA,MAAc,kBACZ,UACoB;AACpB,UAAM,OAAO,SAAS;AAGtB,QAAI;AACJ,QAAI;AAEJ,QACE,KAAK,OAAO,KACZ,OAAO,KAAK,OAAO,MAAM,YACzB,SAAS,KAAK,OAAO,GACrB;AACA,YAAM,WAAW,KAAK,OAAO;AAC7B,iBAAW,SAAS;AACpB,oBAAc,SAAS;AAAA,IACzB,WAAW,OAAO,KAAK,cAAc,UAAU;AAC7C,iBAAW,KAAK;AAAA,IAClB;AAEA,QAAI,CAAC,UAAU;AACb,YAAM,IAAI,MAAM,yCAAyC;AAAA,IAC3D;AAKA,UAAM,gBAAgB,MAAM,MAAM,QAAQ;AAC1C,QAAI,CAAC,cAAc,IAAI;AACrB,YAAM,IAAI;AAAA,QACR,8BAA8B,QAAQ,KAAK,cAAc,MAAM,IAAI,cAAc,UAAU;AAAA,MAAA;AAAA,IAE/F;AACA,UAAM,cAAc,MAAM,cAAc,YAAA;AACxC,UAAM,SAAS,oBAAoB,WAAW;AAI9C,UAAM,kBAAkB,aAAa,MAAM,GAAG,EAAE,CAAC,GAAG,KAAA;AACpD,UAAM,mBAAmB,oBAAoB,QAAQ;AAKrD,UAAM,YACJ,oBAAoB,iBAAiB,MAAM,GAAG,EAAE,CAAC,KAAK;AACxD,UAAM,SAAS,cAAc,SAAS,QAAQ;AAE9C,UAAM,QAAQ,cAAc,kBAAkB,SAAS,SAAS,CAAC;AAEjE,WAAO;AAAA,MACL,IAAI,SAAS,aAAa,KAAK,WAAA;AAAA,MAC/B,OAAO,KAAK;AAAA,MACZ,OAAO;AAAA,MACP;AAAA,MACA,aAAa,mBAAmB,SAAS,MAAM;AAAA,MAC/C,GAAI,QAAQ,EAAE,UAAU,CAAA;AAAA,IAAC;AAAA,EAE7B;AACF;AAEO,SAAS,UACd,OACA,QAC0B;AAC1B,SAAO,IAAI,iBAAiB,OAAO,MAAM;AAC3C;"}
@@ -1,6 +1,7 @@
1
1
  import { fal } from "@fal-ai/client";
2
2
  import { BaseTranscriptionAdapter } from "@tanstack/ai/adapters";
3
3
  import { configureFalClient, dataUrlToBlob, generateId } from "../utils/client.js";
4
+ import { buildFalUsage, takeBillableUnits } from "../utils/billing.js";
4
5
  class FalTranscriptionAdapter extends BaseTranscriptionAdapter {
5
6
  name = "fal";
6
7
  constructor(model, config) {
@@ -71,12 +72,14 @@ class FalTranscriptionAdapter extends BaseTranscriptionAdapter {
71
72
  });
72
73
  }
73
74
  const language = data.language || data.inferred_languages?.[0] || data.languages?.[0];
75
+ const usage = buildFalUsage(takeBillableUnits(response.requestId));
74
76
  return {
75
77
  id: response.requestId || this.generateId(),
76
78
  model: this.model,
77
79
  text,
78
80
  ...language !== void 0 ? { language } : {},
79
- ...segments !== void 0 ? { segments } : {}
81
+ ...segments !== void 0 ? { segments } : {},
82
+ ...usage ? { usage } : {}
80
83
  };
81
84
  }
82
85
  }
@@ -1 +1 @@
1
- {"version":3,"file":"transcription.js","sources":["../../../src/adapters/transcription.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseTranscriptionAdapter } from '@tanstack/ai/adapters'\nimport {\n configureFalClient,\n dataUrlToBlob,\n generateId as utilGenerateId,\n} from '../utils'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type {\n TranscriptionOptions,\n TranscriptionResult,\n TranscriptionSegment,\n} from '@tanstack/ai'\nimport type { FalClientConfig } from '../utils'\nimport type { FalModel, FalModelInput } from '../model-meta'\n\n/**\n * Provider options for transcription, excluding fields TanStack AI handles.\n */\nexport type FalTranscriptionProviderOptions<TModel extends string> = Omit<\n FalModelInput<TModel>,\n 'audio_url'\n>\n\ninterface FalChunk {\n text: string\n timestamp?: [number, number] | null\n speaker?: string\n}\n\n/**\n * fal.ai transcription (speech-to-text) adapter.\n *\n * Supports fal.ai STT models like whisper, wizper, etc.\n *\n * @example\n * ```typescript\n * const adapter = falTranscription('fal-ai/whisper')\n * const result = await generateTranscription({\n * adapter,\n * audio: 'https://example.com/audio.mp3',\n * language: 'en',\n * })\n * ```\n */\nexport class FalTranscriptionAdapter<\n TModel extends FalModel,\n> extends BaseTranscriptionAdapter<\n TModel,\n FalTranscriptionProviderOptions<TModel>\n> {\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super(model, {})\n configureFalClient(config)\n }\n\n async transcribe(\n options: TranscriptionOptions<FalTranscriptionProviderOptions<TModel>>,\n ): Promise<TranscriptionResult> {\n const { logger } = options\n logger.request(\n `activity=generateTranscription provider=fal model=${this.model}`,\n {\n provider: 'fal',\n model: this.model,\n },\n )\n try {\n const input = this.buildInput(options)\n const result = await fal.subscribe(this.model, { input })\n return this.transformResponse(result)\n } catch (error) {\n logger.errors('fal.generateTranscription fatal', {\n error,\n source: 'fal.generateTranscription',\n })\n throw error\n }\n }\n\n private buildInput(\n options: TranscriptionOptions<FalTranscriptionProviderOptions<TModel>>,\n ): FalModelInput<TModel> {\n // fal-client auto-uploads Blob/File inputs via fal.storage.upload, but\n // passes strings through unchanged — so a `data:` URL would reach fal's\n // API and get rejected with a 422 \"Unsupported data URL\". Decode data\n // URLs to a Blob up front so the auto-upload path handles them.\n let audioInput: string | Blob | File\n if (options.audio instanceof ArrayBuffer) {\n audioInput = new Blob([options.audio])\n } else if (typeof options.audio === 'string') {\n audioInput = dataUrlToBlob(options.audio) ?? options.audio\n } else {\n audioInput = options.audio\n }\n\n const input = {\n ...options.modelOptions,\n audio_url: audioInput,\n ...(options.language ? { language: options.language } : {}),\n ...(options.prompt ? { prompt: options.prompt } : {}),\n } as FalModelInput<TModel>\n return input\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n\n private transformResponse(\n response: Result<OutputType<TModel>>,\n ): TranscriptionResult {\n const data = response.data as Record<string, unknown>\n\n const text = (data.text as string) || ''\n\n // Map fal chunks to TanStack segments. fal whisper can return\n // `timestamp: null` on some chunks (e.g. when word-level timing is\n // disabled), and the runtime payload is not actually constrained to a\n // 2-tuple — treat it as unknown and validate before indexing.\n let segments: Array<TranscriptionSegment> | undefined\n const chunks = data.chunks as Array<FalChunk> | undefined\n if (chunks && Array.isArray(chunks)) {\n segments = chunks.flatMap((chunk, index) => {\n const ts = chunk.timestamp as unknown\n if (\n !Array.isArray(ts) ||\n ts.length < 2 ||\n typeof ts[0] !== 'number' ||\n typeof ts[1] !== 'number'\n ) {\n return []\n }\n return [\n {\n id: index,\n start: ts[0],\n end: ts[1],\n text: chunk.text,\n ...(chunk.speaker ? { speaker: chunk.speaker } : {}),\n },\n ]\n })\n }\n\n // Extract language from response\n const language =\n (data.language as string) ||\n (data.inferred_languages as Array<string> | undefined)?.[0] ||\n (data.languages as Array<string> | undefined)?.[0]\n\n return {\n id: response.requestId || this.generateId(),\n model: this.model,\n text,\n ...(language !== undefined ? { language } : {}),\n ...(segments !== undefined ? { segments } : {}),\n }\n }\n}\n\nexport function falTranscription<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalTranscriptionAdapter<TModel> {\n return new FalTranscriptionAdapter(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;AA6CO,MAAM,gCAEH,yBAGR;AAAA,EACS,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,OAAO,EAAE;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,WACJ,SAC8B;AAC9B,UAAM,EAAE,WAAW;AACnB,WAAO;AAAA,MACL,qDAAqD,KAAK,KAAK;AAAA,MAC/D;AAAA,QACE,UAAU;AAAA,QACV,OAAO,KAAK;AAAA,MAAA;AAAA,IACd;AAEF,QAAI;AACF,YAAM,QAAQ,KAAK,WAAW,OAAO;AACrC,YAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO,EAAE,OAAO;AACxD,aAAO,KAAK,kBAAkB,MAAM;AAAA,IACtC,SAAS,OAAO;AACd,aAAO,OAAO,mCAAmC;AAAA,QAC/C;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,WACN,SACuB;AAKvB,QAAI;AACJ,QAAI,QAAQ,iBAAiB,aAAa;AACxC,mBAAa,IAAI,KAAK,CAAC,QAAQ,KAAK,CAAC;AAAA,IACvC,WAAW,OAAO,QAAQ,UAAU,UAAU;AAC5C,mBAAa,cAAc,QAAQ,KAAK,KAAK,QAAQ;AAAA,IACvD,OAAO;AACL,mBAAa,QAAQ;AAAA,IACvB;AAEA,UAAM,QAAQ;AAAA,MACZ,GAAG,QAAQ;AAAA,MACX,WAAW;AAAA,MACX,GAAI,QAAQ,WAAW,EAAE,UAAU,QAAQ,SAAA,IAAa,CAAA;AAAA,MACxD,GAAI,QAAQ,SAAS,EAAE,QAAQ,QAAQ,OAAA,IAAW,CAAA;AAAA,IAAC;AAErD,WAAO;AAAA,EACT;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AAAA,EAEQ,kBACN,UACqB;AACrB,UAAM,OAAO,SAAS;AAEtB,UAAM,OAAQ,KAAK,QAAmB;AAMtC,QAAI;AACJ,UAAM,SAAS,KAAK;AACpB,QAAI,UAAU,MAAM,QAAQ,MAAM,GAAG;AACnC,iBAAW,OAAO,QAAQ,CAAC,OAAO,UAAU;AAC1C,cAAM,KAAK,MAAM;AACjB,YACE,CAAC,MAAM,QAAQ,EAAE,KACjB,GAAG,SAAS,KACZ,OAAO,GAAG,CAAC,MAAM,YACjB,OAAO,GAAG,CAAC,MAAM,UACjB;AACA,iBAAO,CAAA;AAAA,QACT;AACA,eAAO;AAAA,UACL;AAAA,YACE,IAAI;AAAA,YACJ,OAAO,GAAG,CAAC;AAAA,YACX,KAAK,GAAG,CAAC;AAAA,YACT,MAAM,MAAM;AAAA,YACZ,GAAI,MAAM,UAAU,EAAE,SAAS,MAAM,QAAA,IAAY,CAAA;AAAA,UAAC;AAAA,QACpD;AAAA,MAEJ,CAAC;AAAA,IACH;AAGA,UAAM,WACH,KAAK,YACL,KAAK,qBAAmD,CAAC,KACzD,KAAK,YAA0C,CAAC;AAEnD,WAAO;AAAA,MACL,IAAI,SAAS,aAAa,KAAK,WAAA;AAAA,MAC/B,OAAO,KAAK;AAAA,MACZ;AAAA,MACA,GAAI,aAAa,SAAY,EAAE,SAAA,IAAa,CAAA;AAAA,MAC5C,GAAI,aAAa,SAAY,EAAE,aAAa,CAAA;AAAA,IAAC;AAAA,EAEjD;AACF;AAEO,SAAS,iBACd,OACA,QACiC;AACjC,SAAO,IAAI,wBAAwB,OAAO,MAAM;AAClD;"}
1
+ {"version":3,"file":"transcription.js","sources":["../../../src/adapters/transcription.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseTranscriptionAdapter } from '@tanstack/ai/adapters'\nimport {\n buildFalUsage,\n configureFalClient,\n dataUrlToBlob,\n takeBillableUnits,\n generateId as utilGenerateId,\n} from '../utils'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type {\n TranscriptionOptions,\n TranscriptionResult,\n TranscriptionSegment,\n} from '@tanstack/ai'\nimport type { FalClientConfig } from '../utils'\nimport type { FalModel, FalModelInput } from '../model-meta'\n\n/**\n * Provider options for transcription, excluding fields TanStack AI handles.\n */\nexport type FalTranscriptionProviderOptions<TModel extends string> = Omit<\n FalModelInput<TModel>,\n 'audio_url'\n>\n\ninterface FalChunk {\n text: string\n timestamp?: [number, number] | null\n speaker?: string\n}\n\n/**\n * fal.ai transcription (speech-to-text) adapter.\n *\n * Supports fal.ai STT models like whisper, wizper, etc.\n *\n * @example\n * ```typescript\n * const adapter = falTranscription('fal-ai/whisper')\n * const result = await generateTranscription({\n * adapter,\n * audio: 'https://example.com/audio.mp3',\n * language: 'en',\n * })\n * ```\n */\nexport class FalTranscriptionAdapter<\n TModel extends FalModel,\n> extends BaseTranscriptionAdapter<\n TModel,\n FalTranscriptionProviderOptions<TModel>\n> {\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super(model, {})\n configureFalClient(config)\n }\n\n async transcribe(\n options: TranscriptionOptions<FalTranscriptionProviderOptions<TModel>>,\n ): Promise<TranscriptionResult> {\n const { logger } = options\n logger.request(\n `activity=generateTranscription provider=fal model=${this.model}`,\n {\n provider: 'fal',\n model: this.model,\n },\n )\n try {\n const input = this.buildInput(options)\n const result = await fal.subscribe(this.model, { input })\n return this.transformResponse(result)\n } catch (error) {\n logger.errors('fal.generateTranscription fatal', {\n error,\n source: 'fal.generateTranscription',\n })\n throw error\n }\n }\n\n private buildInput(\n options: TranscriptionOptions<FalTranscriptionProviderOptions<TModel>>,\n ): FalModelInput<TModel> {\n // fal-client auto-uploads Blob/File inputs via fal.storage.upload, but\n // passes strings through unchanged — so a `data:` URL would reach fal's\n // API and get rejected with a 422 \"Unsupported data URL\". Decode data\n // URLs to a Blob up front so the auto-upload path handles them.\n let audioInput: string | Blob | File\n if (options.audio instanceof ArrayBuffer) {\n audioInput = new Blob([options.audio])\n } else if (typeof options.audio === 'string') {\n audioInput = dataUrlToBlob(options.audio) ?? options.audio\n } else {\n audioInput = options.audio\n }\n\n const input = {\n ...options.modelOptions,\n audio_url: audioInput,\n ...(options.language ? { language: options.language } : {}),\n ...(options.prompt ? { prompt: options.prompt } : {}),\n } as FalModelInput<TModel>\n return input\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n\n private transformResponse(\n response: Result<OutputType<TModel>>,\n ): TranscriptionResult {\n const data = response.data as Record<string, unknown>\n\n const text = (data.text as string) || ''\n\n // Map fal chunks to TanStack segments. fal whisper can return\n // `timestamp: null` on some chunks (e.g. when word-level timing is\n // disabled), and the runtime payload is not actually constrained to a\n // 2-tuple — treat it as unknown and validate before indexing.\n let segments: Array<TranscriptionSegment> | undefined\n const chunks = data.chunks as Array<FalChunk> | undefined\n if (chunks && Array.isArray(chunks)) {\n segments = chunks.flatMap((chunk, index) => {\n const ts = chunk.timestamp as unknown\n if (\n !Array.isArray(ts) ||\n ts.length < 2 ||\n typeof ts[0] !== 'number' ||\n typeof ts[1] !== 'number'\n ) {\n return []\n }\n return [\n {\n id: index,\n start: ts[0],\n end: ts[1],\n text: chunk.text,\n ...(chunk.speaker ? { speaker: chunk.speaker } : {}),\n },\n ]\n })\n }\n\n // Extract language from response\n const language =\n (data.language as string) ||\n (data.inferred_languages as Array<string> | undefined)?.[0] ||\n (data.languages as Array<string> | undefined)?.[0]\n\n const usage = buildFalUsage(takeBillableUnits(response.requestId))\n\n return {\n id: response.requestId || this.generateId(),\n model: this.model,\n text,\n ...(language !== undefined ? { language } : {}),\n ...(segments !== undefined ? { segments } : {}),\n ...(usage ? { usage } : {}),\n }\n }\n}\n\nexport function falTranscription<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalTranscriptionAdapter<TModel> {\n return new FalTranscriptionAdapter(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;;AA+CO,MAAM,gCAEH,yBAGR;AAAA,EACS,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,OAAO,EAAE;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,WACJ,SAC8B;AAC9B,UAAM,EAAE,WAAW;AACnB,WAAO;AAAA,MACL,qDAAqD,KAAK,KAAK;AAAA,MAC/D;AAAA,QACE,UAAU;AAAA,QACV,OAAO,KAAK;AAAA,MAAA;AAAA,IACd;AAEF,QAAI;AACF,YAAM,QAAQ,KAAK,WAAW,OAAO;AACrC,YAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO,EAAE,OAAO;AACxD,aAAO,KAAK,kBAAkB,MAAM;AAAA,IACtC,SAAS,OAAO;AACd,aAAO,OAAO,mCAAmC;AAAA,QAC/C;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEQ,WACN,SACuB;AAKvB,QAAI;AACJ,QAAI,QAAQ,iBAAiB,aAAa;AACxC,mBAAa,IAAI,KAAK,CAAC,QAAQ,KAAK,CAAC;AAAA,IACvC,WAAW,OAAO,QAAQ,UAAU,UAAU;AAC5C,mBAAa,cAAc,QAAQ,KAAK,KAAK,QAAQ;AAAA,IACvD,OAAO;AACL,mBAAa,QAAQ;AAAA,IACvB;AAEA,UAAM,QAAQ;AAAA,MACZ,GAAG,QAAQ;AAAA,MACX,WAAW;AAAA,MACX,GAAI,QAAQ,WAAW,EAAE,UAAU,QAAQ,SAAA,IAAa,CAAA;AAAA,MACxD,GAAI,QAAQ,SAAS,EAAE,QAAQ,QAAQ,OAAA,IAAW,CAAA;AAAA,IAAC;AAErD,WAAO;AAAA,EACT;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AAAA,EAEQ,kBACN,UACqB;AACrB,UAAM,OAAO,SAAS;AAEtB,UAAM,OAAQ,KAAK,QAAmB;AAMtC,QAAI;AACJ,UAAM,SAAS,KAAK;AACpB,QAAI,UAAU,MAAM,QAAQ,MAAM,GAAG;AACnC,iBAAW,OAAO,QAAQ,CAAC,OAAO,UAAU;AAC1C,cAAM,KAAK,MAAM;AACjB,YACE,CAAC,MAAM,QAAQ,EAAE,KACjB,GAAG,SAAS,KACZ,OAAO,GAAG,CAAC,MAAM,YACjB,OAAO,GAAG,CAAC,MAAM,UACjB;AACA,iBAAO,CAAA;AAAA,QACT;AACA,eAAO;AAAA,UACL;AAAA,YACE,IAAI;AAAA,YACJ,OAAO,GAAG,CAAC;AAAA,YACX,KAAK,GAAG,CAAC;AAAA,YACT,MAAM,MAAM;AAAA,YACZ,GAAI,MAAM,UAAU,EAAE,SAAS,MAAM,QAAA,IAAY,CAAA;AAAA,UAAC;AAAA,QACpD;AAAA,MAEJ,CAAC;AAAA,IACH;AAGA,UAAM,WACH,KAAK,YACL,KAAK,qBAAmD,CAAC,KACzD,KAAK,YAA0C,CAAC;AAEnD,UAAM,QAAQ,cAAc,kBAAkB,SAAS,SAAS,CAAC;AAEjE,WAAO;AAAA,MACL,IAAI,SAAS,aAAa,KAAK,WAAA;AAAA,MAC/B,OAAO,KAAK;AAAA,MACZ;AAAA,MACA,GAAI,aAAa,SAAY,EAAE,SAAA,IAAa,CAAA;AAAA,MAC5C,GAAI,aAAa,SAAY,EAAE,SAAA,IAAa,CAAA;AAAA,MAC5C,GAAI,QAAQ,EAAE,UAAU,CAAA;AAAA,IAAC;AAAA,EAE7B;AACF;AAEO,SAAS,iBACd,OACA,QACiC;AACjC,SAAO,IAAI,wBAAwB,OAAO,MAAM;AAClD;"}
@@ -1,6 +1,7 @@
1
1
  import { fal } from "@fal-ai/client";
2
2
  import { BaseVideoAdapter } from "@tanstack/ai/adapters";
3
3
  import { configureFalClient, generateId } from "../utils/client.js";
4
+ import { buildFalUsage, takeBillableUnits } from "../utils/billing.js";
4
5
  import { mapVideoSizeToFalFormat } from "../video/video-provider-options.js";
5
6
  function mapFalStatusToVideoStatus(falStatus) {
6
7
  switch (falStatus) {
@@ -87,9 +88,11 @@ class FalVideoAdapter extends BaseVideoAdapter {
87
88
  if (!url) {
88
89
  throw new Error("Video URL not found in response");
89
90
  }
91
+ const usage = buildFalUsage(takeBillableUnits(result.requestId));
90
92
  return {
91
93
  jobId,
92
- url
94
+ url,
95
+ ...usage ? { usage } : {}
93
96
  };
94
97
  }
95
98
  generateId() {
@@ -1 +1 @@
1
- {"version":3,"file":"video.js","sources":["../../../src/adapters/video.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseVideoAdapter } from '@tanstack/ai/adapters'\nimport { configureFalClient, generateId as utilGenerateId } from '../utils'\nimport { mapVideoSizeToFalFormat } from '../video/video-provider-options'\nimport type {\n VideoGenerationOptions,\n VideoJobResult,\n VideoStatusResult,\n VideoUrlResult,\n} from '@tanstack/ai'\nimport type {\n FalModel,\n FalModelInput,\n FalModelVideoSize,\n FalVideoProviderOptions,\n} from '../model-meta'\nimport type { FalClientConfig } from '../utils'\n\ntype FalQueueStatus = 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED'\n\ninterface FalStatusResponse {\n status: FalQueueStatus\n queue_position?: number\n logs?: Array<{ message: string }>\n}\n\ninterface FalVideoResultData {\n video?: { url: string }\n video_url?: string\n}\n\n/**\n * Maps fal.ai queue status to TanStack AI video status.\n *\n * Note: fal.ai does not return a FAILED queue status. Errors surface\n * as exceptions when fetching results from a COMPLETED job (e.g. 422\n * validation errors). Those are handled in getVideoUrl().\n */\nfunction mapFalStatusToVideoStatus(\n falStatus: FalQueueStatus,\n): VideoStatusResult['status'] {\n switch (falStatus) {\n case 'IN_QUEUE':\n return 'pending'\n case 'IN_PROGRESS':\n return 'processing'\n case 'COMPLETED':\n return 'completed'\n default:\n return 'processing'\n }\n}\n\n/**\n * fal.ai video generation adapter.\n * Supports MiniMax, Luma, Kling, Hunyuan, and other fal.ai video models.\n *\n * Uses fal.ai's comprehensive type system to provide autocomplete\n * and type safety for all supported video models.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<\n TModel,\n FalVideoProviderOptions<TModel>,\n Record<TModel, FalVideoProviderOptions<TModel>>,\n Record<TModel, FalModelVideoSize<TModel>>\n> {\n override readonly kind = 'video' as const\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super({}, model)\n configureFalClient(config)\n }\n\n async createVideoJob(\n options: VideoGenerationOptions<\n FalVideoProviderOptions<TModel>,\n FalModelVideoSize<TModel>\n >,\n ): Promise<VideoJobResult> {\n const { prompt, size, duration, modelOptions, logger } = options\n\n logger.request(`activity=generateVideo provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n\n try {\n const sizeParams = mapVideoSizeToFalFormat(size)\n\n const input = {\n ...modelOptions,\n ...sizeParams,\n prompt,\n ...(duration ? { duration } : {}),\n } as FalModelInput<TModel>\n\n // Submit to queue and get request ID\n const { request_id } = await fal.queue.submit(this.model, {\n input,\n })\n\n return {\n jobId: request_id,\n model: this.model,\n }\n } catch (error) {\n logger.errors('fal.createVideoJob fatal', {\n error,\n source: 'fal.createVideoJob',\n })\n throw error\n }\n }\n\n async getVideoStatus(jobId: string): Promise<VideoStatusResult> {\n const statusResponse = (await fal.queue.status(this.model, {\n requestId: jobId,\n logs: true,\n })) as FalStatusResponse\n\n return {\n jobId,\n status: mapFalStatusToVideoStatus(statusResponse.status),\n ...(statusResponse.queue_position != null\n ? {\n progress: Math.max(0, 100 - statusResponse.queue_position * 10),\n }\n : {}),\n }\n }\n\n async getVideoUrl(jobId: string): Promise<VideoUrlResult> {\n let result\n try {\n result = await fal.queue.result(this.model, {\n requestId: jobId,\n })\n } catch (error: unknown) {\n // fal.ai may report COMPLETED status but throw on result fetch\n // (e.g. 422 validation errors). Extract the detailed error info.\n const err = error as { body?: { detail?: unknown }; message?: string }\n const detail = err.body?.detail\n if (Array.isArray(detail)) {\n const messages = detail.map(\n (d: { msg?: string; loc?: Array<string> }) =>\n d.loc ? `${d.loc.join('.')}: ${d.msg}` : d.msg,\n )\n throw new Error(`Video generation failed: ${messages.join('; ')}`)\n }\n throw new Error(\n `Failed to retrieve video result: ${err.message || String(error)}`,\n )\n }\n\n const data = result.data as FalVideoResultData\n\n // Different models return video URL in different formats\n const url = data.video?.url || data.video_url\n if (!url) {\n throw new Error('Video URL not found in response')\n }\n\n return {\n jobId,\n url,\n }\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n}\n\n/**\n * Create a fal.ai video adapter with an explicit API key.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport function createFalVideo<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalVideoAdapter<TModel> {\n return new FalVideoAdapter(model, config)\n}\n\n/**\n * Create a fal.ai video adapter using config.apiKey or the FAL_KEY environment variable.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport function falVideo<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalVideoAdapter<TModel> {\n return createFalVideo(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;;AAsCA,SAAS,0BACP,WAC6B;AAC7B,UAAQ,WAAA;AAAA,IACN,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EAAA;AAEb;AAWO,MAAM,wBAAiD,iBAK5D;AAAA,EACkB,OAAO;AAAA,EAChB,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,CAAA,GAAI,KAAK;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,eACJ,SAIyB;AACzB,UAAM,EAAE,QAAQ,MAAM,UAAU,cAAc,WAAW;AAEzD,WAAO,QAAQ,6CAA6C,KAAK,KAAK,IAAI;AAAA,MACxE,UAAU;AAAA,MACV,OAAO,KAAK;AAAA,IAAA,CACb;AAED,QAAI;AACF,YAAM,aAAa,wBAAwB,IAAI;AAE/C,YAAM,QAAQ;AAAA,QACZ,GAAG;AAAA,QACH,GAAG;AAAA,QACH;AAAA,QACA,GAAI,WAAW,EAAE,aAAa,CAAA;AAAA,MAAC;AAIjC,YAAM,EAAE,eAAe,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO;AAAA,QACxD;AAAA,MAAA,CACD;AAED,aAAO;AAAA,QACL,OAAO;AAAA,QACP,OAAO,KAAK;AAAA,MAAA;AAAA,IAEhB,SAAS,OAAO;AACd,aAAO,OAAO,4BAA4B;AAAA,QACxC;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,MAAM,eAAe,OAA2C;AAC9D,UAAM,iBAAkB,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO;AAAA,MACzD,WAAW;AAAA,MACX,MAAM;AAAA,IAAA,CACP;AAED,WAAO;AAAA,MACL;AAAA,MACA,QAAQ,0BAA0B,eAAe,MAAM;AAAA,MACvD,GAAI,eAAe,kBAAkB,OACjC;AAAA,QACE,UAAU,KAAK,IAAI,GAAG,MAAM,eAAe,iBAAiB,EAAE;AAAA,MAAA,IAEhE,CAAA;AAAA,IAAC;AAAA,EAET;AAAA,EAEA,MAAM,YAAY,OAAwC;AACxD,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO;AAAA,QAC1C,WAAW;AAAA,MAAA,CACZ;AAAA,IACH,SAAS,OAAgB;AAGvB,YAAM,MAAM;AACZ,YAAM,SAAS,IAAI,MAAM;AACzB,UAAI,MAAM,QAAQ,MAAM,GAAG;AACzB,cAAM,WAAW,OAAO;AAAA,UACtB,CAAC,MACC,EAAE,MAAM,GAAG,EAAE,IAAI,KAAK,GAAG,CAAC,KAAK,EAAE,GAAG,KAAK,EAAE;AAAA,QAAA;AAE/C,cAAM,IAAI,MAAM,4BAA4B,SAAS,KAAK,IAAI,CAAC,EAAE;AAAA,MACnE;AACA,YAAM,IAAI;AAAA,QACR,oCAAoC,IAAI,WAAW,OAAO,KAAK,CAAC;AAAA,MAAA;AAAA,IAEpE;AAEA,UAAM,OAAO,OAAO;AAGpB,UAAM,MAAM,KAAK,OAAO,OAAO,KAAK;AACpC,QAAI,CAAC,KAAK;AACR,YAAM,IAAI,MAAM,iCAAiC;AAAA,IACnD;AAEA,WAAO;AAAA,MACL;AAAA,MACA;AAAA,IAAA;AAAA,EAEJ;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AACF;AAOO,SAAS,eACd,OACA,QACyB;AACzB,SAAO,IAAI,gBAAgB,OAAO,MAAM;AAC1C;AAOO,SAAS,SACd,OACA,QACyB;AACzB,SAAO,eAAe,OAAO,MAAM;AACrC;"}
1
+ {"version":3,"file":"video.js","sources":["../../../src/adapters/video.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { BaseVideoAdapter } from '@tanstack/ai/adapters'\nimport {\n buildFalUsage,\n configureFalClient,\n takeBillableUnits,\n generateId as utilGenerateId,\n} from '../utils'\nimport { mapVideoSizeToFalFormat } from '../video/video-provider-options'\nimport type {\n VideoGenerationOptions,\n VideoJobResult,\n VideoStatusResult,\n VideoUrlResult,\n} from '@tanstack/ai'\nimport type {\n FalModel,\n FalModelInput,\n FalModelVideoSize,\n FalVideoProviderOptions,\n} from '../model-meta'\nimport type { FalClientConfig } from '../utils'\n\ntype FalQueueStatus = 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED'\n\ninterface FalStatusResponse {\n status: FalQueueStatus\n queue_position?: number\n logs?: Array<{ message: string }>\n}\n\ninterface FalVideoResultData {\n video?: { url: string }\n video_url?: string\n}\n\n/**\n * Maps fal.ai queue status to TanStack AI video status.\n *\n * Note: fal.ai does not return a FAILED queue status. Errors surface\n * as exceptions when fetching results from a COMPLETED job (e.g. 422\n * validation errors). Those are handled in getVideoUrl().\n */\nfunction mapFalStatusToVideoStatus(\n falStatus: FalQueueStatus,\n): VideoStatusResult['status'] {\n switch (falStatus) {\n case 'IN_QUEUE':\n return 'pending'\n case 'IN_PROGRESS':\n return 'processing'\n case 'COMPLETED':\n return 'completed'\n default:\n return 'processing'\n }\n}\n\n/**\n * fal.ai video generation adapter.\n * Supports MiniMax, Luma, Kling, Hunyuan, and other fal.ai video models.\n *\n * Uses fal.ai's comprehensive type system to provide autocomplete\n * and type safety for all supported video models.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<\n TModel,\n FalVideoProviderOptions<TModel>,\n Record<TModel, FalVideoProviderOptions<TModel>>,\n Record<TModel, FalModelVideoSize<TModel>>\n> {\n override readonly kind = 'video' as const\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super({}, model)\n configureFalClient(config)\n }\n\n async createVideoJob(\n options: VideoGenerationOptions<\n FalVideoProviderOptions<TModel>,\n FalModelVideoSize<TModel>\n >,\n ): Promise<VideoJobResult> {\n const { prompt, size, duration, modelOptions, logger } = options\n\n logger.request(`activity=generateVideo provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n\n try {\n const sizeParams = mapVideoSizeToFalFormat(size)\n\n const input = {\n ...modelOptions,\n ...sizeParams,\n prompt,\n ...(duration ? { duration } : {}),\n } as FalModelInput<TModel>\n\n // Submit to queue and get request ID\n const { request_id } = await fal.queue.submit(this.model, {\n input,\n })\n\n return {\n jobId: request_id,\n model: this.model,\n }\n } catch (error) {\n logger.errors('fal.createVideoJob fatal', {\n error,\n source: 'fal.createVideoJob',\n })\n throw error\n }\n }\n\n async getVideoStatus(jobId: string): Promise<VideoStatusResult> {\n const statusResponse = (await fal.queue.status(this.model, {\n requestId: jobId,\n logs: true,\n })) as FalStatusResponse\n\n return {\n jobId,\n status: mapFalStatusToVideoStatus(statusResponse.status),\n ...(statusResponse.queue_position != null\n ? {\n progress: Math.max(0, 100 - statusResponse.queue_position * 10),\n }\n : {}),\n }\n }\n\n async getVideoUrl(jobId: string): Promise<VideoUrlResult> {\n let result\n try {\n result = await fal.queue.result(this.model, {\n requestId: jobId,\n })\n } catch (error: unknown) {\n // fal.ai may report COMPLETED status but throw on result fetch\n // (e.g. 422 validation errors). Extract the detailed error info.\n const err = error as { body?: { detail?: unknown }; message?: string }\n const detail = err.body?.detail\n if (Array.isArray(detail)) {\n const messages = detail.map(\n (d: { msg?: string; loc?: Array<string> }) =>\n d.loc ? `${d.loc.join('.')}: ${d.msg}` : d.msg,\n )\n throw new Error(`Video generation failed: ${messages.join('; ')}`)\n }\n throw new Error(\n `Failed to retrieve video result: ${err.message || String(error)}`,\n )\n }\n\n const data = result.data as FalVideoResultData\n\n // Different models return video URL in different formats\n const url = data.video?.url || data.video_url\n if (!url) {\n throw new Error('Video URL not found in response')\n }\n\n const usage = buildFalUsage(takeBillableUnits(result.requestId))\n\n return {\n jobId,\n url,\n ...(usage ? { usage } : {}),\n }\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n}\n\n/**\n * Create a fal.ai video adapter with an explicit API key.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport function createFalVideo<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalVideoAdapter<TModel> {\n return new FalVideoAdapter(model, config)\n}\n\n/**\n * Create a fal.ai video adapter using config.apiKey or the FAL_KEY environment variable.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport function falVideo<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalVideoAdapter<TModel> {\n return createFalVideo(model, config)\n}\n"],"names":["utilGenerateId"],"mappings":";;;;;AA2CA,SAAS,0BACP,WAC6B;AAC7B,UAAQ,WAAA;AAAA,IACN,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EAAA;AAEb;AAWO,MAAM,wBAAiD,iBAK5D;AAAA,EACkB,OAAO;AAAA,EAChB,OAAO;AAAA,EAEhB,YAAY,OAAe,QAA0B;AACnD,UAAM,CAAA,GAAI,KAAK;AACf,uBAAmB,MAAM;AAAA,EAC3B;AAAA,EAEA,MAAM,eACJ,SAIyB;AACzB,UAAM,EAAE,QAAQ,MAAM,UAAU,cAAc,WAAW;AAEzD,WAAO,QAAQ,6CAA6C,KAAK,KAAK,IAAI;AAAA,MACxE,UAAU;AAAA,MACV,OAAO,KAAK;AAAA,IAAA,CACb;AAED,QAAI;AACF,YAAM,aAAa,wBAAwB,IAAI;AAE/C,YAAM,QAAQ;AAAA,QACZ,GAAG;AAAA,QACH,GAAG;AAAA,QACH;AAAA,QACA,GAAI,WAAW,EAAE,aAAa,CAAA;AAAA,MAAC;AAIjC,YAAM,EAAE,eAAe,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO;AAAA,QACxD;AAAA,MAAA,CACD;AAED,aAAO;AAAA,QACL,OAAO;AAAA,QACP,OAAO,KAAK;AAAA,MAAA;AAAA,IAEhB,SAAS,OAAO;AACd,aAAO,OAAO,4BAA4B;AAAA,QACxC;AAAA,QACA,QAAQ;AAAA,MAAA,CACT;AACD,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,MAAM,eAAe,OAA2C;AAC9D,UAAM,iBAAkB,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO;AAAA,MACzD,WAAW;AAAA,MACX,MAAM;AAAA,IAAA,CACP;AAED,WAAO;AAAA,MACL;AAAA,MACA,QAAQ,0BAA0B,eAAe,MAAM;AAAA,MACvD,GAAI,eAAe,kBAAkB,OACjC;AAAA,QACE,UAAU,KAAK,IAAI,GAAG,MAAM,eAAe,iBAAiB,EAAE;AAAA,MAAA,IAEhE,CAAA;AAAA,IAAC;AAAA,EAET;AAAA,EAEA,MAAM,YAAY,OAAwC;AACxD,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO;AAAA,QAC1C,WAAW;AAAA,MAAA,CACZ;AAAA,IACH,SAAS,OAAgB;AAGvB,YAAM,MAAM;AACZ,YAAM,SAAS,IAAI,MAAM;AACzB,UAAI,MAAM,QAAQ,MAAM,GAAG;AACzB,cAAM,WAAW,OAAO;AAAA,UACtB,CAAC,MACC,EAAE,MAAM,GAAG,EAAE,IAAI,KAAK,GAAG,CAAC,KAAK,EAAE,GAAG,KAAK,EAAE;AAAA,QAAA;AAE/C,cAAM,IAAI,MAAM,4BAA4B,SAAS,KAAK,IAAI,CAAC,EAAE;AAAA,MACnE;AACA,YAAM,IAAI;AAAA,QACR,oCAAoC,IAAI,WAAW,OAAO,KAAK,CAAC;AAAA,MAAA;AAAA,IAEpE;AAEA,UAAM,OAAO,OAAO;AAGpB,UAAM,MAAM,KAAK,OAAO,OAAO,KAAK;AACpC,QAAI,CAAC,KAAK;AACR,YAAM,IAAI,MAAM,iCAAiC;AAAA,IACnD;AAEA,UAAM,QAAQ,cAAc,kBAAkB,OAAO,SAAS,CAAC;AAE/D,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,GAAI,QAAQ,EAAE,UAAU,CAAA;AAAA,IAAC;AAAA,EAE7B;AAAA,EAEmB,aAAqB;AACtC,WAAOA,WAAe,KAAK,IAAI;AAAA,EACjC;AACF;AAOO,SAAS,eACd,OACA,QACyB;AACzB,SAAO,IAAI,gBAAgB,OAAO,MAAM;AAC1C;AAOO,SAAS,SACd,OACA,QACyB;AACzB,SAAO,eAAe,OAAO,MAAM;AACrC;"}
@@ -0,0 +1,39 @@
1
+ import { TokenUsage } from '@tanstack/ai';
2
+ /**
3
+ * Parse the `x-fal-billable-units` header value into a finite number. Returns
4
+ * `undefined` for a missing or non-numeric value so callers can skip attaching
5
+ * usage rather than surfacing `NaN`.
6
+ */
7
+ export declare function parseBillableUnits(value: string | null): number | undefined;
8
+ /**
9
+ * Record the billable units carried by a fal result response, keyed by the
10
+ * request id from the same response. Reading headers does not consume the body,
11
+ * so the response can be returned to the fal client untouched.
12
+ */
13
+ export declare function recordBillableUnitsFromResponse(response: Response): void;
14
+ /**
15
+ * Read and remove the billable units recorded for a request id. Removing on read
16
+ * keeps the registry from growing across the lifetime of the process.
17
+ */
18
+ export declare function takeBillableUnits(requestId: string | undefined): number | undefined;
19
+ /**
20
+ * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
21
+ * no tokens, so the token fields are zero and the real billing signal rides on
22
+ * `unitsBilled` — mirroring how the duration-billed transcription adapters
23
+ * surface `durationSeconds`. Returns `undefined` when no units were captured so
24
+ * callers can omit `usage` entirely.
25
+ */
26
+ export declare function buildFalUsage(unitsBilled: number | undefined): TokenUsage | undefined;
27
+ /**
28
+ * Wrap a fetch so every fal request's response is inspected for the
29
+ * `x-fal-billable-units` header before being returned untouched. Installed as
30
+ * fal's `config.fetch`, which (unlike a global `responseHandler`) is honoured for
31
+ * every request — the fal client forces `resultResponseHandler` per queue
32
+ * operation, clobbering any configured response handler.
33
+ *
34
+ * `baseFetch` is the underlying implementation to delegate to (defaults to the
35
+ * global `fetch`). Injecting it keeps usage capture working when a caller
36
+ * supplies a custom fetch — a proxy, instrumentation, or a test mock — without
37
+ * mutating any global.
38
+ */
39
+ export declare function createBillingFetch(baseFetch?: typeof fetch): typeof fetch;
@@ -0,0 +1,55 @@
1
+ const FAL_BILLABLE_UNITS_HEADER = "x-fal-billable-units";
2
+ const FAL_REQUEST_ID_HEADER = "x-fal-request-id";
3
+ const MAX_PENDING_ENTRIES = 256;
4
+ const billableUnitsByRequestId = /* @__PURE__ */ new Map();
5
+ function parseBillableUnits(value) {
6
+ if (value == null || value === "") return void 0;
7
+ const parsed = Number(value);
8
+ return Number.isFinite(parsed) ? parsed : void 0;
9
+ }
10
+ function recordBillableUnitsFromResponse(response) {
11
+ const units = parseBillableUnits(
12
+ response.headers.get(FAL_BILLABLE_UNITS_HEADER)
13
+ );
14
+ if (units == null) return;
15
+ const requestId = response.headers.get(FAL_REQUEST_ID_HEADER);
16
+ if (!requestId) return;
17
+ if (billableUnitsByRequestId.size >= MAX_PENDING_ENTRIES && !billableUnitsByRequestId.has(requestId)) {
18
+ const oldest = billableUnitsByRequestId.keys().next().value;
19
+ if (oldest !== void 0) billableUnitsByRequestId.delete(oldest);
20
+ }
21
+ billableUnitsByRequestId.set(requestId, units);
22
+ }
23
+ function takeBillableUnits(requestId) {
24
+ if (!requestId) return void 0;
25
+ const units = billableUnitsByRequestId.get(requestId);
26
+ if (units !== void 0) billableUnitsByRequestId.delete(requestId);
27
+ return units;
28
+ }
29
+ function buildFalUsage(unitsBilled) {
30
+ if (unitsBilled == null) return void 0;
31
+ return {
32
+ promptTokens: 0,
33
+ completionTokens: 0,
34
+ totalTokens: 0,
35
+ unitsBilled
36
+ };
37
+ }
38
+ function createBillingFetch(baseFetch = globalThis.fetch) {
39
+ return async (input, init) => {
40
+ const response = await baseFetch(input, init);
41
+ try {
42
+ recordBillableUnitsFromResponse(response);
43
+ } catch {
44
+ }
45
+ return response;
46
+ };
47
+ }
48
+ export {
49
+ buildFalUsage,
50
+ createBillingFetch,
51
+ parseBillableUnits,
52
+ recordBillableUnitsFromResponse,
53
+ takeBillableUnits
54
+ };
55
+ //# sourceMappingURL=billing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing.js","sources":["../../../src/utils/billing.ts"],"sourcesContent":["import type { TokenUsage } from '@tanstack/ai'\n\n/**\n * Response header fal sets on a queue *result* fetch carrying the real billed\n * quantity for the generation, denominated in the endpoint's priced unit.\n */\nconst FAL_BILLABLE_UNITS_HEADER = 'x-fal-billable-units'\n\n/**\n * Response header fal sets carrying the request id. The fal client surfaces this\n * same value as `Result.requestId`, so keying captured billable units by it\n * guarantees the adapter's lookup matches the fetch the units came from — no URL\n * parsing or global correlation registry of our own design needed.\n */\nconst FAL_REQUEST_ID_HEADER = 'x-fal-request-id'\n\n/**\n * Upper bound on retained, not-yet-consumed billable-unit entries. Each\n * successful generation reads-and-deletes its entry (see {@link takeBillableUnits}),\n * so this only guards against an unbounded leak when a result fetch records units\n * but the adapter never resolves (e.g. it throws before reading). When the cap is\n * exceeded the oldest entry is evicted (Map preserves insertion order).\n */\nconst MAX_PENDING_ENTRIES = 256\n\nconst billableUnitsByRequestId = new Map<string, number>()\n\n/**\n * Parse the `x-fal-billable-units` header value into a finite number. Returns\n * `undefined` for a missing or non-numeric value so callers can skip attaching\n * usage rather than surfacing `NaN`.\n */\nexport function parseBillableUnits(value: string | null): number | undefined {\n if (value == null || value === '') return undefined\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * Record the billable units carried by a fal result response, keyed by the\n * request id from the same response. Reading headers does not consume the body,\n * so the response can be returned to the fal client untouched.\n */\nexport function recordBillableUnitsFromResponse(response: Response): void {\n const units = parseBillableUnits(\n response.headers.get(FAL_BILLABLE_UNITS_HEADER),\n )\n if (units == null) return\n const requestId = response.headers.get(FAL_REQUEST_ID_HEADER)\n if (!requestId) return\n if (\n billableUnitsByRequestId.size >= MAX_PENDING_ENTRIES &&\n !billableUnitsByRequestId.has(requestId)\n ) {\n const oldest = billableUnitsByRequestId.keys().next().value\n if (oldest !== undefined) billableUnitsByRequestId.delete(oldest)\n }\n billableUnitsByRequestId.set(requestId, units)\n}\n\n/**\n * Read and remove the billable units recorded for a request id. Removing on read\n * keeps the registry from growing across the lifetime of the process.\n */\nexport function takeBillableUnits(\n requestId: string | undefined,\n): number | undefined {\n if (!requestId) return undefined\n const units = billableUnitsByRequestId.get(requestId)\n if (units !== undefined) billableUnitsByRequestId.delete(requestId)\n return units\n}\n\n/**\n * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has\n * no tokens, so the token fields are zero and the real billing signal rides on\n * `unitsBilled` — mirroring how the duration-billed transcription adapters\n * surface `durationSeconds`. Returns `undefined` when no units were captured so\n * callers can omit `usage` entirely.\n */\nexport function buildFalUsage(\n unitsBilled: number | undefined,\n): TokenUsage | undefined {\n if (unitsBilled == null) return undefined\n return {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n unitsBilled,\n }\n}\n\n/**\n * Wrap a fetch so every fal request's response is inspected for the\n * `x-fal-billable-units` header before being returned untouched. Installed as\n * fal's `config.fetch`, which (unlike a global `responseHandler`) is honoured for\n * every request — the fal client forces `resultResponseHandler` per queue\n * operation, clobbering any configured response handler.\n *\n * `baseFetch` is the underlying implementation to delegate to (defaults to the\n * global `fetch`). Injecting it keeps usage capture working when a caller\n * supplies a custom fetch — a proxy, instrumentation, or a test mock — without\n * mutating any global.\n */\nexport function createBillingFetch(\n baseFetch: typeof fetch = globalThis.fetch,\n): typeof fetch {\n return async (input, init) => {\n const response = await baseFetch(input, init)\n try {\n recordBillableUnitsFromResponse(response)\n } catch {\n // Capturing usage must never break the underlying request.\n }\n return response\n }\n}\n"],"names":[],"mappings":"AAMA,MAAM,4BAA4B;AAQlC,MAAM,wBAAwB;AAS9B,MAAM,sBAAsB;AAE5B,MAAM,+CAA+B,IAAA;AAO9B,SAAS,mBAAmB,OAA0C;AAC3E,MAAI,SAAS,QAAQ,UAAU,GAAI,QAAO;AAC1C,QAAM,SAAS,OAAO,KAAK;AAC3B,SAAO,OAAO,SAAS,MAAM,IAAI,SAAS;AAC5C;AAOO,SAAS,gCAAgC,UAA0B;AACxE,QAAM,QAAQ;AAAA,IACZ,SAAS,QAAQ,IAAI,yBAAyB;AAAA,EAAA;AAEhD,MAAI,SAAS,KAAM;AACnB,QAAM,YAAY,SAAS,QAAQ,IAAI,qBAAqB;AAC5D,MAAI,CAAC,UAAW;AAChB,MACE,yBAAyB,QAAQ,uBACjC,CAAC,yBAAyB,IAAI,SAAS,GACvC;AACA,UAAM,SAAS,yBAAyB,KAAA,EAAO,OAAO;AACtD,QAAI,WAAW,OAAW,0BAAyB,OAAO,MAAM;AAAA,EAClE;AACA,2BAAyB,IAAI,WAAW,KAAK;AAC/C;AAMO,SAAS,kBACd,WACoB;AACpB,MAAI,CAAC,UAAW,QAAO;AACvB,QAAM,QAAQ,yBAAyB,IAAI,SAAS;AACpD,MAAI,UAAU,OAAW,0BAAyB,OAAO,SAAS;AAClE,SAAO;AACT;AASO,SAAS,cACd,aACwB;AACxB,MAAI,eAAe,KAAM,QAAO;AAChC,SAAO;AAAA,IACL,cAAc;AAAA,IACd,kBAAkB;AAAA,IAClB,aAAa;AAAA,IACb;AAAA,EAAA;AAEJ;AAcO,SAAS,mBACd,YAA0B,WAAW,OACvB;AACd,SAAO,OAAO,OAAO,SAAS;AAC5B,UAAM,WAAW,MAAM,UAAU,OAAO,IAAI;AAC5C,QAAI;AACF,sCAAgC,QAAQ;AAAA,IAC1C,QAAQ;AAAA,IAER;AACA,WAAO;AAAA,EACT;AACF;"}
@@ -1,6 +1,13 @@
1
1
  export interface FalClientConfig {
2
2
  apiKey: string;
3
3
  proxyUrl?: string;
4
+ /**
5
+ * Override the underlying fetch used for fal requests. The adapter wraps it to
6
+ * read the `x-fal-billable-units` header (see ./billing.ts), so usage capture
7
+ * still works. Defaults to the global `fetch`. Useful for proxying,
8
+ * instrumentation, or pointing requests at a mock in tests.
9
+ */
10
+ fetch?: typeof fetch;
4
11
  }
5
12
  export declare function getFalApiKeyFromEnv(): string;
6
13
  export declare function configureFalClient(config?: FalClientConfig): void;
@@ -1,5 +1,6 @@
1
1
  import { fal } from "@fal-ai/client";
2
- import { getApiKeyFromEnv, generateId as generateId$1 } from "@tanstack/ai-utils";
2
+ import { generateId as generateId$1, getApiKeyFromEnv } from "@tanstack/ai-utils";
3
+ import { createBillingFetch } from "./billing.js";
3
4
  function getFalApiKeyFromEnv() {
4
5
  return getApiKeyFromEnv("FAL_KEY");
5
6
  }
@@ -7,6 +8,10 @@ function configureFalClient(config) {
7
8
  const apiKey = config?.apiKey ?? getFalApiKeyFromEnv();
8
9
  fal.config({
9
10
  credentials: apiKey,
11
+ // Wrap the (optionally overridden) fetch to read fal's
12
+ // `x-fal-billable-units` header off every response so adapters can surface
13
+ // the billed quantity as `result.usage`. See ./billing.ts.
14
+ fetch: createBillingFetch(config?.fetch),
10
15
  ...config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}
11
16
  });
12
17
  }
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sources":["../../../src/utils/client.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils'\n\nexport interface FalClientConfig {\n apiKey: string\n proxyUrl?: string\n}\n\nexport function getFalApiKeyFromEnv(): string {\n return getApiKeyFromEnv('FAL_KEY')\n}\n\nexport function configureFalClient(config?: FalClientConfig): void {\n const apiKey = config?.apiKey ?? getFalApiKeyFromEnv()\n fal.config({\n credentials: apiKey,\n ...(config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}),\n })\n}\n\nexport function generateId(prefix: string): string {\n return _generateId(prefix)\n}\n\n/**\n * Extract a safe file extension from a URL. Strips query strings, URL\n * fragments, and any trailing slashes, and only returns the extension when\n * it looks like a real one (2-5 alphanumeric chars). Returns undefined\n * otherwise so callers can fall back to a default.\n */\nexport function extractUrlExtension(url: string): string | undefined {\n // Parse via URL when possible so we only look at the pathname and never\n // mistake a TLD (e.g. the `.com` in `https://x.com/`) for a file extension.\n let pathname: string\n try {\n const parsed = new URL(url)\n pathname = parsed.pathname\n } catch {\n // Fall back to treating the input as a raw path when URL parsing fails\n // (e.g. the caller passed a bare path). Still strip ?query and #fragment.\n pathname = url.split('?')[0]?.split('#')[0] ?? url\n }\n // Drop trailing slashes so `/path/audio.mp3/` still yields `mp3`.\n const normalized = pathname.replace(/\\/+$/, '')\n // Require at least one `/` — otherwise we're looking at an empty pathname\n // (bare-host URLs like `https://x.com/` land here after stripping the\n // trailing slash).\n if (!normalized.includes('/')) return undefined\n const lastSegment = normalized.split('/').pop()\n if (!lastSegment) return undefined\n const extension = lastSegment.split('.').pop()\n if (!extension || extension === lastSegment) return undefined\n return /^[a-z0-9]{2,5}$/i.test(extension) ? extension : undefined\n}\n\n/**\n * Derive a reasonable audio content-type. Prefers the explicit MIME (stripped\n * of parameters), then an extension-based lookup for common audio formats,\n * otherwise falls back to audio/mpeg — fal URLs virtually always serve mp3.\n */\nexport function deriveAudioContentType(\n explicitContentType: string | undefined,\n url: string,\n): string {\n const stripped = explicitContentType?.split(';')[0]?.trim()\n if (stripped) return stripped\n\n const ext = extractUrlExtension(url)?.toLowerCase()\n switch (ext) {\n case 'mp3':\n return 'audio/mpeg'\n case 'wav':\n return 'audio/wav'\n case 'ogg':\n case 'oga':\n return 'audio/ogg'\n case 'flac':\n return 'audio/flac'\n case 'aac':\n return 'audio/aac'\n case 'm4a':\n case 'mp4':\n return 'audio/mp4'\n case 'webm':\n return 'audio/webm'\n case undefined:\n default:\n return 'audio/mpeg'\n }\n}\n\n/**\n * Decode a `data:` URL into a Blob so fal-client can auto-upload it via\n * `fal.storage.upload`. fal's inference API rejects data URLs with a 422\n * \"Unsupported data URL\", so we convert them before handing them off.\n *\n * Supports both base64 and URL-encoded data URLs. Returns `undefined` for\n * anything that isn't a data URL, so callers can fall through to other\n * handling (http URLs are passed to fal as-is).\n */\nexport function dataUrlToBlob(value: string): Blob | undefined {\n if (!value.startsWith('data:')) return undefined\n const commaIndex = value.indexOf(',')\n if (commaIndex === -1) return undefined\n\n const header = value.slice(5, commaIndex)\n const payload = value.slice(commaIndex + 1)\n const isBase64 = /;base64$/i.test(header)\n const mimeType = header.split(';')[0] || 'application/octet-stream'\n\n if (isBase64) {\n const binary = atob(payload)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i += 1) {\n bytes[i] = binary.charCodeAt(i)\n }\n return new Blob([bytes], { type: mimeType })\n }\n\n return new Blob([decodeURIComponent(payload)], { type: mimeType })\n}\n\n/**\n * Convert an ArrayBuffer to base64 in a cross-runtime way.\n *\n * The naive `btoa(String.fromCharCode(...bytes))` form blows up V8's argument\n * limit (~65k) on realistic audio payloads, so we either use `Buffer` (Node /\n * Bun) or walk the byte array in a single loop (browser).\n */\nexport function arrayBufferToBase64(bytes: ArrayBuffer): string {\n if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {\n return Buffer.from(bytes).toString('base64')\n }\n const view = new Uint8Array(bytes)\n let binary = ''\n for (const byte of view) {\n binary += String.fromCharCode(byte)\n }\n return btoa(binary)\n}\n"],"names":["_generateId"],"mappings":";;AAQO,SAAS,sBAA8B;AAC5C,SAAO,iBAAiB,SAAS;AACnC;AAEO,SAAS,mBAAmB,QAAgC;AACjE,QAAM,SAAS,QAAQ,UAAU,oBAAA;AACjC,MAAI,OAAO;AAAA,IACT,aAAa;AAAA,IACb,GAAI,QAAQ,WAAW,EAAE,UAAU,OAAO,SAAA,IAAa,CAAA;AAAA,EAAC,CACzD;AACH;AAEO,SAAS,WAAW,QAAwB;AACjD,SAAOA,aAAY,MAAM;AAC3B;AAQO,SAAS,oBAAoB,KAAiC;AAGnE,MAAI;AACJ,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,eAAW,OAAO;AAAA,EACpB,QAAQ;AAGN,eAAW,IAAI,MAAM,GAAG,EAAE,CAAC,GAAG,MAAM,GAAG,EAAE,CAAC,KAAK;AAAA,EACjD;AAEA,QAAM,aAAa,SAAS,QAAQ,QAAQ,EAAE;AAI9C,MAAI,CAAC,WAAW,SAAS,GAAG,EAAG,QAAO;AACtC,QAAM,cAAc,WAAW,MAAM,GAAG,EAAE,IAAA;AAC1C,MAAI,CAAC,YAAa,QAAO;AACzB,QAAM,YAAY,YAAY,MAAM,GAAG,EAAE,IAAA;AACzC,MAAI,CAAC,aAAa,cAAc,YAAa,QAAO;AACpD,SAAO,mBAAmB,KAAK,SAAS,IAAI,YAAY;AAC1D;AAOO,SAAS,uBACd,qBACA,KACQ;AACR,QAAM,WAAW,qBAAqB,MAAM,GAAG,EAAE,CAAC,GAAG,KAAA;AACrD,MAAI,SAAU,QAAO;AAErB,QAAM,MAAM,oBAAoB,GAAG,GAAG,YAAA;AACtC,UAAQ,KAAA;AAAA,IACN,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL;AACE,aAAO;AAAA,EAAA;AAEb;AAWO,SAAS,cAAc,OAAiC;AAC7D,MAAI,CAAC,MAAM,WAAW,OAAO,EAAG,QAAO;AACvC,QAAM,aAAa,MAAM,QAAQ,GAAG;AACpC,MAAI,eAAe,GAAI,QAAO;AAE9B,QAAM,SAAS,MAAM,MAAM,GAAG,UAAU;AACxC,QAAM,UAAU,MAAM,MAAM,aAAa,CAAC;AAC1C,QAAM,WAAW,YAAY,KAAK,MAAM;AACxC,QAAM,WAAW,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK;AAEzC,MAAI,UAAU;AACZ,UAAM,SAAS,KAAK,OAAO;AAC3B,UAAM,QAAQ,IAAI,WAAW,OAAO,MAAM;AAC1C,aAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK,GAAG;AACzC,YAAM,CAAC,IAAI,OAAO,WAAW,CAAC;AAAA,IAChC;AACA,WAAO,IAAI,KAAK,CAAC,KAAK,GAAG,EAAE,MAAM,UAAU;AAAA,EAC7C;AAEA,SAAO,IAAI,KAAK,CAAC,mBAAmB,OAAO,CAAC,GAAG,EAAE,MAAM,UAAU;AACnE;AASO,SAAS,oBAAoB,OAA4B;AAC9D,MAAI,OAAO,WAAW,eAAe,OAAO,OAAO,SAAS,YAAY;AACtE,WAAO,OAAO,KAAK,KAAK,EAAE,SAAS,QAAQ;AAAA,EAC7C;AACA,QAAM,OAAO,IAAI,WAAW,KAAK;AACjC,MAAI,SAAS;AACb,aAAW,QAAQ,MAAM;AACvB,cAAU,OAAO,aAAa,IAAI;AAAA,EACpC;AACA,SAAO,KAAK,MAAM;AACpB;"}
1
+ {"version":3,"file":"client.js","sources":["../../../src/utils/client.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils'\nimport { createBillingFetch } from './billing'\n\nexport interface FalClientConfig {\n apiKey: string\n proxyUrl?: string\n /**\n * Override the underlying fetch used for fal requests. The adapter wraps it to\n * read the `x-fal-billable-units` header (see ./billing.ts), so usage capture\n * still works. Defaults to the global `fetch`. Useful for proxying,\n * instrumentation, or pointing requests at a mock in tests.\n */\n fetch?: typeof fetch\n}\n\nexport function getFalApiKeyFromEnv(): string {\n return getApiKeyFromEnv('FAL_KEY')\n}\n\nexport function configureFalClient(config?: FalClientConfig): void {\n const apiKey = config?.apiKey ?? getFalApiKeyFromEnv()\n fal.config({\n credentials: apiKey,\n // Wrap the (optionally overridden) fetch to read fal's\n // `x-fal-billable-units` header off every response so adapters can surface\n // the billed quantity as `result.usage`. See ./billing.ts.\n fetch: createBillingFetch(config?.fetch),\n ...(config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}),\n })\n}\n\nexport function generateId(prefix: string): string {\n return _generateId(prefix)\n}\n\n/**\n * Extract a safe file extension from a URL. Strips query strings, URL\n * fragments, and any trailing slashes, and only returns the extension when\n * it looks like a real one (2-5 alphanumeric chars). Returns undefined\n * otherwise so callers can fall back to a default.\n */\nexport function extractUrlExtension(url: string): string | undefined {\n // Parse via URL when possible so we only look at the pathname and never\n // mistake a TLD (e.g. the `.com` in `https://x.com/`) for a file extension.\n let pathname: string\n try {\n const parsed = new URL(url)\n pathname = parsed.pathname\n } catch {\n // Fall back to treating the input as a raw path when URL parsing fails\n // (e.g. the caller passed a bare path). Still strip ?query and #fragment.\n pathname = url.split('?')[0]?.split('#')[0] ?? url\n }\n // Drop trailing slashes so `/path/audio.mp3/` still yields `mp3`.\n const normalized = pathname.replace(/\\/+$/, '')\n // Require at least one `/` — otherwise we're looking at an empty pathname\n // (bare-host URLs like `https://x.com/` land here after stripping the\n // trailing slash).\n if (!normalized.includes('/')) return undefined\n const lastSegment = normalized.split('/').pop()\n if (!lastSegment) return undefined\n const extension = lastSegment.split('.').pop()\n if (!extension || extension === lastSegment) return undefined\n return /^[a-z0-9]{2,5}$/i.test(extension) ? extension : undefined\n}\n\n/**\n * Derive a reasonable audio content-type. Prefers the explicit MIME (stripped\n * of parameters), then an extension-based lookup for common audio formats,\n * otherwise falls back to audio/mpeg — fal URLs virtually always serve mp3.\n */\nexport function deriveAudioContentType(\n explicitContentType: string | undefined,\n url: string,\n): string {\n const stripped = explicitContentType?.split(';')[0]?.trim()\n if (stripped) return stripped\n\n const ext = extractUrlExtension(url)?.toLowerCase()\n switch (ext) {\n case 'mp3':\n return 'audio/mpeg'\n case 'wav':\n return 'audio/wav'\n case 'ogg':\n case 'oga':\n return 'audio/ogg'\n case 'flac':\n return 'audio/flac'\n case 'aac':\n return 'audio/aac'\n case 'm4a':\n case 'mp4':\n return 'audio/mp4'\n case 'webm':\n return 'audio/webm'\n case undefined:\n default:\n return 'audio/mpeg'\n }\n}\n\n/**\n * Decode a `data:` URL into a Blob so fal-client can auto-upload it via\n * `fal.storage.upload`. fal's inference API rejects data URLs with a 422\n * \"Unsupported data URL\", so we convert them before handing them off.\n *\n * Supports both base64 and URL-encoded data URLs. Returns `undefined` for\n * anything that isn't a data URL, so callers can fall through to other\n * handling (http URLs are passed to fal as-is).\n */\nexport function dataUrlToBlob(value: string): Blob | undefined {\n if (!value.startsWith('data:')) return undefined\n const commaIndex = value.indexOf(',')\n if (commaIndex === -1) return undefined\n\n const header = value.slice(5, commaIndex)\n const payload = value.slice(commaIndex + 1)\n const isBase64 = /;base64$/i.test(header)\n const mimeType = header.split(';')[0] || 'application/octet-stream'\n\n if (isBase64) {\n const binary = atob(payload)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i += 1) {\n bytes[i] = binary.charCodeAt(i)\n }\n return new Blob([bytes], { type: mimeType })\n }\n\n return new Blob([decodeURIComponent(payload)], { type: mimeType })\n}\n\n/**\n * Convert an ArrayBuffer to base64 in a cross-runtime way.\n *\n * The naive `btoa(String.fromCharCode(...bytes))` form blows up V8's argument\n * limit (~65k) on realistic audio payloads, so we either use `Buffer` (Node /\n * Bun) or walk the byte array in a single loop (browser).\n */\nexport function arrayBufferToBase64(bytes: ArrayBuffer): string {\n if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {\n return Buffer.from(bytes).toString('base64')\n }\n const view = new Uint8Array(bytes)\n let binary = ''\n for (const byte of view) {\n binary += String.fromCharCode(byte)\n }\n return btoa(binary)\n}\n"],"names":["_generateId"],"mappings":";;;AAgBO,SAAS,sBAA8B;AAC5C,SAAO,iBAAiB,SAAS;AACnC;AAEO,SAAS,mBAAmB,QAAgC;AACjE,QAAM,SAAS,QAAQ,UAAU,oBAAA;AACjC,MAAI,OAAO;AAAA,IACT,aAAa;AAAA;AAAA;AAAA;AAAA,IAIb,OAAO,mBAAmB,QAAQ,KAAK;AAAA,IACvC,GAAI,QAAQ,WAAW,EAAE,UAAU,OAAO,SAAA,IAAa,CAAA;AAAA,EAAC,CACzD;AACH;AAEO,SAAS,WAAW,QAAwB;AACjD,SAAOA,aAAY,MAAM;AAC3B;AAQO,SAAS,oBAAoB,KAAiC;AAGnE,MAAI;AACJ,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,eAAW,OAAO;AAAA,EACpB,QAAQ;AAGN,eAAW,IAAI,MAAM,GAAG,EAAE,CAAC,GAAG,MAAM,GAAG,EAAE,CAAC,KAAK;AAAA,EACjD;AAEA,QAAM,aAAa,SAAS,QAAQ,QAAQ,EAAE;AAI9C,MAAI,CAAC,WAAW,SAAS,GAAG,EAAG,QAAO;AACtC,QAAM,cAAc,WAAW,MAAM,GAAG,EAAE,IAAA;AAC1C,MAAI,CAAC,YAAa,QAAO;AACzB,QAAM,YAAY,YAAY,MAAM,GAAG,EAAE,IAAA;AACzC,MAAI,CAAC,aAAa,cAAc,YAAa,QAAO;AACpD,SAAO,mBAAmB,KAAK,SAAS,IAAI,YAAY;AAC1D;AAOO,SAAS,uBACd,qBACA,KACQ;AACR,QAAM,WAAW,qBAAqB,MAAM,GAAG,EAAE,CAAC,GAAG,KAAA;AACrD,MAAI,SAAU,QAAO;AAErB,QAAM,MAAM,oBAAoB,GAAG,GAAG,YAAA;AACtC,UAAQ,KAAA;AAAA,IACN,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL;AACE,aAAO;AAAA,EAAA;AAEb;AAWO,SAAS,cAAc,OAAiC;AAC7D,MAAI,CAAC,MAAM,WAAW,OAAO,EAAG,QAAO;AACvC,QAAM,aAAa,MAAM,QAAQ,GAAG;AACpC,MAAI,eAAe,GAAI,QAAO;AAE9B,QAAM,SAAS,MAAM,MAAM,GAAG,UAAU;AACxC,QAAM,UAAU,MAAM,MAAM,aAAa,CAAC;AAC1C,QAAM,WAAW,YAAY,KAAK,MAAM;AACxC,QAAM,WAAW,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK;AAEzC,MAAI,UAAU;AACZ,UAAM,SAAS,KAAK,OAAO;AAC3B,UAAM,QAAQ,IAAI,WAAW,OAAO,MAAM;AAC1C,aAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK,GAAG;AACzC,YAAM,CAAC,IAAI,OAAO,WAAW,CAAC;AAAA,IAChC;AACA,WAAO,IAAI,KAAK,CAAC,KAAK,GAAG,EAAE,MAAM,UAAU;AAAA,EAC7C;AAEA,SAAO,IAAI,KAAK,CAAC,mBAAmB,OAAO,CAAC,GAAG,EAAE,MAAM,UAAU;AACnE;AASO,SAAS,oBAAoB,OAA4B;AAC9D,MAAI,OAAO,WAAW,eAAe,OAAO,OAAO,SAAS,YAAY;AACtE,WAAO,OAAO,KAAK,KAAK,EAAE,SAAS,QAAQ;AAAA,EAC7C;AACA,QAAM,OAAO,IAAI,WAAW,KAAK;AACjC,MAAI,SAAS;AACb,aAAW,QAAQ,MAAM;AACvB,cAAU,OAAO,aAAa,IAAI;AAAA,EACpC;AACA,SAAO,KAAK,MAAM;AACpB;"}
@@ -1 +1,2 @@
1
1
  export { getFalApiKeyFromEnv, configureFalClient, generateId, arrayBufferToBase64, dataUrlToBlob, extractUrlExtension, deriveAudioContentType, type FalClientConfig, } from './client.js';
2
+ export { takeBillableUnits, buildFalUsage } from './billing.js';
package/package.json CHANGED
@@ -1,14 +1,22 @@
1
1
  {
2
2
  "name": "@tanstack/ai-fal",
3
- "version": "0.7.23",
3
+ "version": "0.8.2",
4
4
  "description": "fal.ai adapter for TanStack AI image, video, audio, speech, and transcription generation.",
5
- "author": "",
5
+ "author": "Tanner Linsley",
6
6
  "license": "MIT",
7
+ "homepage": "https://tanstack.com/ai",
7
8
  "repository": {
8
9
  "type": "git",
9
10
  "url": "git+https://github.com/TanStack/ai.git",
10
11
  "directory": "packages/ai-fal"
11
12
  },
13
+ "bugs": {
14
+ "url": "https://github.com/TanStack/ai/issues"
15
+ },
16
+ "funding": {
17
+ "type": "github",
18
+ "url": "https://github.com/sponsors/tannerlinsley"
19
+ },
12
20
  "type": "module",
13
21
  "module": "./dist/esm/index.js",
14
22
  "types": "./dist/esm/index.d.ts",
@@ -41,15 +49,15 @@
41
49
  ],
42
50
  "dependencies": {
43
51
  "@fal-ai/client": "^1.10.1",
44
- "@tanstack/ai-utils": "0.2.1"
52
+ "@tanstack/ai-utils": "0.2.2"
45
53
  },
46
54
  "devDependencies": {
47
55
  "@vitest/coverage-v8": "4.0.14",
48
56
  "vite": "^7.3.3",
49
- "@tanstack/ai": "0.28.0"
57
+ "@tanstack/ai": "0.31.0"
50
58
  },
51
59
  "peerDependencies": {
52
- "@tanstack/ai": "0.28.0"
60
+ "@tanstack/ai": "0.31.0"
53
61
  },
54
62
  "scripts": {
55
63
  "build": "vite build",
@@ -1,8 +1,10 @@
1
1
  import { fal } from '@fal-ai/client'
2
2
  import { BaseAudioAdapter } from '@tanstack/ai/adapters'
3
3
  import {
4
+ buildFalUsage,
4
5
  configureFalClient,
5
6
  deriveAudioContentType,
7
+ takeBillableUnits,
6
8
  generateId as utilGenerateId,
7
9
  } from '../utils'
8
10
  import type { OutputType, Result } from '@fal-ai/client'
@@ -133,6 +135,8 @@ export class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<
133
135
  throw new Error('Audio URL not found in fal audio generation response')
134
136
  }
135
137
 
138
+ const usage = buildFalUsage(takeBillableUnits(response.requestId))
139
+
136
140
  return {
137
141
  id: response.requestId || this.generateId(),
138
142
  model: this.model,
@@ -140,6 +144,7 @@ export class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<
140
144
  url: audioUrl,
141
145
  contentType: deriveAudioContentType(contentType, audioUrl),
142
146
  },
147
+ ...(usage ? { usage } : {}),
143
148
  }
144
149
  }
145
150
  }
@@ -1,6 +1,11 @@
1
1
  import { fal } from '@fal-ai/client'
2
2
  import { BaseImageAdapter } from '@tanstack/ai/adapters'
3
- import { configureFalClient, generateId as utilGenerateId } from '../utils'
3
+ import {
4
+ buildFalUsage,
5
+ configureFalClient,
6
+ takeBillableUnits,
7
+ generateId as utilGenerateId,
8
+ } from '../utils'
4
9
  import { mapSizeToFalFormat } from '../image/image-provider-options'
5
10
  import type { OutputType, Result } from '@fal-ai/client'
6
11
  import type { FalClientConfig } from '../utils'
@@ -120,10 +125,13 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
120
125
  )
121
126
  }
122
127
 
128
+ const usage = buildFalUsage(takeBillableUnits(response.requestId))
129
+
123
130
  return {
124
131
  id: response.requestId || this.generateId(),
125
132
  model: this.model,
126
133
  images,
134
+ ...(usage ? { usage } : {}),
127
135
  }
128
136
  }
129
137
 
@@ -2,8 +2,10 @@ import { fal } from '@fal-ai/client'
2
2
  import { BaseTTSAdapter } from '@tanstack/ai/adapters'
3
3
  import {
4
4
  arrayBufferToBase64,
5
+ buildFalUsage,
5
6
  configureFalClient,
6
7
  extractUrlExtension,
8
+ takeBillableUnits,
7
9
  generateId as utilGenerateId,
8
10
  } from '../utils'
9
11
  import type { OutputType, Result } from '@fal-ai/client'
@@ -133,12 +135,15 @@ export class FalSpeechAdapter<TModel extends FalModel> extends BaseTTSAdapter<
133
135
  safeUrlExtension || contentTypeMime?.split('/')[1] || 'wav'
134
136
  const format = rawFormat === 'mpeg' ? 'mp3' : rawFormat
135
137
 
138
+ const usage = buildFalUsage(takeBillableUnits(response.requestId))
139
+
136
140
  return {
137
141
  id: response.requestId || this.generateId(),
138
142
  model: this.model,
139
143
  audio: base64,
140
144
  format,
141
145
  contentType: contentTypeMime || `audio/${format}`,
146
+ ...(usage ? { usage } : {}),
142
147
  }
143
148
  }
144
149
  }
@@ -1,8 +1,10 @@
1
1
  import { fal } from '@fal-ai/client'
2
2
  import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters'
3
3
  import {
4
+ buildFalUsage,
4
5
  configureFalClient,
5
6
  dataUrlToBlob,
7
+ takeBillableUnits,
6
8
  generateId as utilGenerateId,
7
9
  } from '../utils'
8
10
  import type { OutputType, Result } from '@fal-ai/client'
@@ -151,12 +153,15 @@ export class FalTranscriptionAdapter<
151
153
  (data.inferred_languages as Array<string> | undefined)?.[0] ||
152
154
  (data.languages as Array<string> | undefined)?.[0]
153
155
 
156
+ const usage = buildFalUsage(takeBillableUnits(response.requestId))
157
+
154
158
  return {
155
159
  id: response.requestId || this.generateId(),
156
160
  model: this.model,
157
161
  text,
158
162
  ...(language !== undefined ? { language } : {}),
159
163
  ...(segments !== undefined ? { segments } : {}),
164
+ ...(usage ? { usage } : {}),
160
165
  }
161
166
  }
162
167
  }
@@ -1,6 +1,11 @@
1
1
  import { fal } from '@fal-ai/client'
2
2
  import { BaseVideoAdapter } from '@tanstack/ai/adapters'
3
- import { configureFalClient, generateId as utilGenerateId } from '../utils'
3
+ import {
4
+ buildFalUsage,
5
+ configureFalClient,
6
+ takeBillableUnits,
7
+ generateId as utilGenerateId,
8
+ } from '../utils'
4
9
  import { mapVideoSizeToFalFormat } from '../video/video-provider-options'
5
10
  import type {
6
11
  VideoGenerationOptions,
@@ -163,9 +168,12 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
163
168
  throw new Error('Video URL not found in response')
164
169
  }
165
170
 
171
+ const usage = buildFalUsage(takeBillableUnits(result.requestId))
172
+
166
173
  return {
167
174
  jobId,
168
175
  url,
176
+ ...(usage ? { usage } : {}),
169
177
  }
170
178
  }
171
179
 
@@ -0,0 +1,117 @@
1
+ import type { TokenUsage } from '@tanstack/ai'
2
+
3
+ /**
4
+ * Response header fal sets on a queue *result* fetch carrying the real billed
5
+ * quantity for the generation, denominated in the endpoint's priced unit.
6
+ */
7
+ const FAL_BILLABLE_UNITS_HEADER = 'x-fal-billable-units'
8
+
9
+ /**
10
+ * Response header fal sets carrying the request id. The fal client surfaces this
11
+ * same value as `Result.requestId`, so keying captured billable units by it
12
+ * guarantees the adapter's lookup matches the fetch the units came from — no URL
13
+ * parsing or global correlation registry of our own design needed.
14
+ */
15
+ const FAL_REQUEST_ID_HEADER = 'x-fal-request-id'
16
+
17
+ /**
18
+ * Upper bound on retained, not-yet-consumed billable-unit entries. Each
19
+ * successful generation reads-and-deletes its entry (see {@link takeBillableUnits}),
20
+ * so this only guards against an unbounded leak when a result fetch records units
21
+ * but the adapter never resolves (e.g. it throws before reading). When the cap is
22
+ * exceeded the oldest entry is evicted (Map preserves insertion order).
23
+ */
24
+ const MAX_PENDING_ENTRIES = 256
25
+
26
+ const billableUnitsByRequestId = new Map<string, number>()
27
+
28
+ /**
29
+ * Parse the `x-fal-billable-units` header value into a finite number. Returns
30
+ * `undefined` for a missing or non-numeric value so callers can skip attaching
31
+ * usage rather than surfacing `NaN`.
32
+ */
33
+ export function parseBillableUnits(value: string | null): number | undefined {
34
+ if (value == null || value === '') return undefined
35
+ const parsed = Number(value)
36
+ return Number.isFinite(parsed) ? parsed : undefined
37
+ }
38
+
39
+ /**
40
+ * Record the billable units carried by a fal result response, keyed by the
41
+ * request id from the same response. Reading headers does not consume the body,
42
+ * so the response can be returned to the fal client untouched.
43
+ */
44
+ export function recordBillableUnitsFromResponse(response: Response): void {
45
+ const units = parseBillableUnits(
46
+ response.headers.get(FAL_BILLABLE_UNITS_HEADER),
47
+ )
48
+ if (units == null) return
49
+ const requestId = response.headers.get(FAL_REQUEST_ID_HEADER)
50
+ if (!requestId) return
51
+ if (
52
+ billableUnitsByRequestId.size >= MAX_PENDING_ENTRIES &&
53
+ !billableUnitsByRequestId.has(requestId)
54
+ ) {
55
+ const oldest = billableUnitsByRequestId.keys().next().value
56
+ if (oldest !== undefined) billableUnitsByRequestId.delete(oldest)
57
+ }
58
+ billableUnitsByRequestId.set(requestId, units)
59
+ }
60
+
61
+ /**
62
+ * Read and remove the billable units recorded for a request id. Removing on read
63
+ * keeps the registry from growing across the lifetime of the process.
64
+ */
65
+ export function takeBillableUnits(
66
+ requestId: string | undefined,
67
+ ): number | undefined {
68
+ if (!requestId) return undefined
69
+ const units = billableUnitsByRequestId.get(requestId)
70
+ if (units !== undefined) billableUnitsByRequestId.delete(requestId)
71
+ return units
72
+ }
73
+
74
+ /**
75
+ * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
76
+ * no tokens, so the token fields are zero and the real billing signal rides on
77
+ * `unitsBilled` — mirroring how the duration-billed transcription adapters
78
+ * surface `durationSeconds`. Returns `undefined` when no units were captured so
79
+ * callers can omit `usage` entirely.
80
+ */
81
+ export function buildFalUsage(
82
+ unitsBilled: number | undefined,
83
+ ): TokenUsage | undefined {
84
+ if (unitsBilled == null) return undefined
85
+ return {
86
+ promptTokens: 0,
87
+ completionTokens: 0,
88
+ totalTokens: 0,
89
+ unitsBilled,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Wrap a fetch so every fal request's response is inspected for the
95
+ * `x-fal-billable-units` header before being returned untouched. Installed as
96
+ * fal's `config.fetch`, which (unlike a global `responseHandler`) is honoured for
97
+ * every request — the fal client forces `resultResponseHandler` per queue
98
+ * operation, clobbering any configured response handler.
99
+ *
100
+ * `baseFetch` is the underlying implementation to delegate to (defaults to the
101
+ * global `fetch`). Injecting it keeps usage capture working when a caller
102
+ * supplies a custom fetch — a proxy, instrumentation, or a test mock — without
103
+ * mutating any global.
104
+ */
105
+ export function createBillingFetch(
106
+ baseFetch: typeof fetch = globalThis.fetch,
107
+ ): typeof fetch {
108
+ return async (input, init) => {
109
+ const response = await baseFetch(input, init)
110
+ try {
111
+ recordBillableUnitsFromResponse(response)
112
+ } catch {
113
+ // Capturing usage must never break the underlying request.
114
+ }
115
+ return response
116
+ }
117
+ }
@@ -1,9 +1,17 @@
1
1
  import { fal } from '@fal-ai/client'
2
2
  import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils'
3
+ import { createBillingFetch } from './billing'
3
4
 
4
5
  export interface FalClientConfig {
5
6
  apiKey: string
6
7
  proxyUrl?: string
8
+ /**
9
+ * Override the underlying fetch used for fal requests. The adapter wraps it to
10
+ * read the `x-fal-billable-units` header (see ./billing.ts), so usage capture
11
+ * still works. Defaults to the global `fetch`. Useful for proxying,
12
+ * instrumentation, or pointing requests at a mock in tests.
13
+ */
14
+ fetch?: typeof fetch
7
15
  }
8
16
 
9
17
  export function getFalApiKeyFromEnv(): string {
@@ -14,6 +22,10 @@ export function configureFalClient(config?: FalClientConfig): void {
14
22
  const apiKey = config?.apiKey ?? getFalApiKeyFromEnv()
15
23
  fal.config({
16
24
  credentials: apiKey,
25
+ // Wrap the (optionally overridden) fetch to read fal's
26
+ // `x-fal-billable-units` header off every response so adapters can surface
27
+ // the billed quantity as `result.usage`. See ./billing.ts.
28
+ fetch: createBillingFetch(config?.fetch),
17
29
  ...(config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}),
18
30
  })
19
31
  }
@@ -8,3 +8,5 @@ export {
8
8
  deriveAudioContentType,
9
9
  type FalClientConfig,
10
10
  } from './client'
11
+
12
+ export { takeBillableUnits, buildFalUsage } from './billing'