@tanstack/ai-fal 0.10.0 → 0.11.0

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.
@@ -69,7 +69,8 @@ var FalSpeechAdapter = class extends BaseTTSAdapter {
69
69
  if (!audioUrl) throw new Error("Audio URL not found in fal TTS response");
70
70
  const audioResponse = await fetch(audioUrl);
71
71
  if (!audioResponse.ok) throw new Error(`Failed to fetch audio from ${audioUrl}: ${audioResponse.status} ${audioResponse.statusText}`);
72
- const base64 = arrayBufferToBase64(await audioResponse.arrayBuffer());
72
+ const arrayBuffer = await audioResponse.arrayBuffer();
73
+ const base64 = arrayBufferToBase64(arrayBuffer);
73
74
  const contentTypeMime = contentType?.split(";")[0]?.trim();
74
75
  const rawFormat = extractUrlExtension(audioUrl) || contentTypeMime?.split("/")[1] || "wav";
75
76
  const format = rawFormat === "mpeg" ? "mp3" : rawFormat;
@@ -1 +1 @@
1
- {"version":3,"file":"speech.js","names":[],"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/client'\nimport { buildFalUsage, takeBillableUnits } from '../utils/billing'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type { TTSOptions, TTSResult } from '@tanstack/ai'\nimport type { FalClientConfig } from '../utils/client'\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 // Request-specific abortSignal only — not fal.config() (global).\n const result = await fal.subscribe(this.model, {\n input,\n ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),\n })\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"],"mappings":";;;;;;;;;;;;;;;;;;;;AAqCA,IAAa,mBAAb,cAA+D,eAG7D;CACA,OAAgB;CAEhB,YAAY,OAAe,QAA0B;EACnD,MAAM,OAAO,CAAC,CAAC;EACf,mBAAmB,MAAM;CAC3B;CAEA,MAAM,eACJ,SACoB;EACpB,MAAM,EAAE,WAAW;EACnB,OAAO,QAAQ,8CAA8C,KAAK,SAAS;GACzE,UAAU;GACV,OAAO,KAAK;EACd,CAAC;EACD,IAAI;GACF,MAAM,QAAQ,KAAK,WAAW,OAAO;GAErC,MAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO;IAC7C;IACA,GAAI,QAAQ,cAAc,EAAE,aAAa,QAAQ,YAAY,IAAI,CAAC;GACpE,CAAC;GACD,OAAO,MAAM,KAAK,kBAAkB,MAAM;EAC5C,SAAS,OAAO;GACd,OAAO,OAAO,4BAA4B;IACxC;IACA,QAAQ;GACV,CAAC;GACD,MAAM;EACR;CACF;CAEA,WACE,SACuB;EASvB,OAAO;GAPL,GAAG,QAAQ;GAEX,QAAQ,QAAQ;GAChB,MAAM,QAAQ;GACd,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAM,IAAI,CAAC;GAChD,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAM,IAAI,CAAC;EAE3C;CACT;CAEA,aAAwC;EACtC,OAAO,WAAe,KAAK,IAAI;CACjC;CAEA,MAAc,kBACZ,UACoB;EACpB,MAAM,OAAO,SAAS;EAGtB,IAAI;EACJ,IAAI;EAEJ,IACE,KAAK,YACL,OAAO,KAAK,aAAa,YACzB,SAAS,KAAK,UACd;GACA,MAAM,WAAW,KAAK;GACtB,WAAW,SAAS;GACpB,cAAc,SAAS;EACzB,OAAO,IAAI,OAAO,KAAK,cAAc,UACnC,WAAW,KAAK;EAGlB,IAAI,CAAC,UACH,MAAM,IAAI,MAAM,yCAAyC;EAM3D,MAAM,gBAAgB,MAAM,MAAM,QAAQ;EAC1C,IAAI,CAAC,cAAc,IACjB,MAAM,IAAI,MACR,8BAA8B,SAAS,IAAI,cAAc,OAAO,GAAG,cAAc,YACnF;EAGF,MAAM,SAAS,oBAAoB,MADT,cAAc,YAAY,CACN;EAI9C,MAAM,kBAAkB,aAAa,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK;EAMzD,MAAM,YALmB,oBAAoB,QAM3C,KAAoB,iBAAiB,MAAM,GAAG,CAAC,CAAC,MAAM;EACxD,MAAM,SAAS,cAAc,SAAS,QAAQ;EAE9C,MAAM,QAAQ,cAAc,kBAAkB,SAAS,SAAS,CAAC;EAEjE,OAAO;GACL,IAAI,SAAS,aAAa,KAAK,WAAW;GAC1C,OAAO,KAAK;GACZ,OAAO;GACP;GACA,aAAa,mBAAmB,SAAS;GACzC,GAAI,QAAQ,EAAE,MAAM,IAAI,CAAC;EAC3B;CACF;AACF;AAEA,SAAgB,UACd,OACA,QAC0B;CAC1B,OAAO,IAAI,iBAAiB,OAAO,MAAM;AAC3C"}
1
+ {"version":3,"file":"speech.js","names":[],"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/client'\nimport { buildFalUsage, takeBillableUnits } from '../utils/billing'\nimport type { OutputType, Result } from '@fal-ai/client'\nimport type { TTSOptions, TTSResult } from '@tanstack/ai'\nimport type { FalClientConfig } from '../utils/client'\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 // Request-specific abortSignal only — not fal.config() (global).\n const result = await fal.subscribe(this.model, {\n input,\n ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),\n })\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"],"mappings":";;;;;;;;;;;;;;;;;;;;AAqCA,IAAa,mBAAb,cAA+D,eAG7D;CACA,OAAgB;CAEhB,YAAY,OAAe,QAA0B;EACnD,MAAM,OAAO,CAAC,CAAC;EACf,mBAAmB,MAAM;CAC3B;CAEA,MAAM,eACJ,SACoB;EACpB,MAAM,EAAE,WAAW;EACnB,OAAO,QAAQ,8CAA8C,KAAK,SAAS;GACzE,UAAU;GACV,OAAO,KAAK;EACd,CAAC;EACD,IAAI;GACF,MAAM,QAAQ,KAAK,WAAW,OAAO;GAErC,MAAM,SAAS,MAAM,IAAI,UAAU,KAAK,OAAO;IAC7C;IACA,GAAI,QAAQ,cAAc,EAAE,aAAa,QAAQ,YAAY,IAAI,CAAC;GACpE,CAAC;GACD,OAAO,MAAM,KAAK,kBAAkB,MAAM;EAC5C,SAAS,OAAO;GACd,OAAO,OAAO,4BAA4B;IACxC;IACA,QAAQ;GACV,CAAC;GACD,MAAM;EACR;CACF;CAEA,WACE,SACuB;EASvB,OAAO;GAPL,GAAG,QAAQ;GAEX,QAAQ,QAAQ;GAChB,MAAM,QAAQ;GACd,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAM,IAAI,CAAC;GAChD,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAM,IAAI,CAAC;EAE3C;CACT;CAEA,aAAwC;EACtC,OAAO,WAAe,KAAK,IAAI;CACjC;CAEA,MAAc,kBACZ,UACoB;EACpB,MAAM,OAAO,SAAS;EAGtB,IAAI;EACJ,IAAI;EAEJ,IACE,KAAK,YACL,OAAO,KAAK,aAAa,YACzB,SAAS,KAAK,UACd;GACA,MAAM,WAAW,KAAK;GACtB,WAAW,SAAS;GACpB,cAAc,SAAS;EACzB,OAAO,IAAI,OAAO,KAAK,cAAc,UACnC,WAAW,KAAK;EAGlB,IAAI,CAAC,UACH,MAAM,IAAI,MAAM,yCAAyC;EAM3D,MAAM,gBAAgB,MAAM,MAAM,QAAQ;EAC1C,IAAI,CAAC,cAAc,IACjB,MAAM,IAAI,MACR,8BAA8B,SAAS,IAAI,cAAc,OAAO,GAAG,cAAc,YACnF;EAEF,MAAM,cAAc,MAAM,cAAc,YAAY;EACpD,MAAM,SAAS,oBAAoB,WAAW;EAI9C,MAAM,kBAAkB,aAAa,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK;EAMzD,MAAM,YALmB,oBAAoB,QAM3C,KAAoB,iBAAiB,MAAM,GAAG,CAAC,CAAC,MAAM;EACxD,MAAM,SAAS,cAAc,SAAS,QAAQ;EAE9C,MAAM,QAAQ,cAAc,kBAAkB,SAAS,SAAS,CAAC;EAEjE,OAAO;GACL,IAAI,SAAS,aAAa,KAAK,WAAW;GAC1C,OAAO,KAAK;GACZ,OAAO;GACP;GACA,aAAa,mBAAmB,SAAS;GACzC,GAAI,QAAQ,EAAE,MAAM,IAAI,CAAC;EAC3B;CACF;AACF;AAEA,SAAgB,UACd,OACA,QAC0B;CAC1B,OAAO,IAAI,iBAAiB,OAAO,MAAM;AAC3C"}
@@ -1 +1 @@
1
- {"version":3,"file":"image-provider-options.js","names":[],"sources":["../../../src/image/image-provider-options.ts"],"sourcesContent":["import type { FalModelImageSize, FalModelImageSizeInput } from '../model-meta'\n\nexport function mapSizeToFalFormat<TModel extends string>(\n size: FalModelImageSize<TModel> | undefined,\n): FalModelImageSizeInput<TModel> | undefined {\n if (!size) return undefined\n\n // \"16:9_4K\" → { aspect_ratio, resolution }\n // \"16:9\" → { aspect_ratio }\n // \"4K\" → { resolution } (no colon, no underscore, model has `resolution`)\n // \"square_hd\" → { image_size } (no colon, no resolution field on model)\n if (typeof size === 'string') {\n if (size.includes('_')) {\n const [first, second] = size.split('_')\n if (first && first.includes(':')) {\n return {\n aspect_ratio: first,\n resolution: second,\n } as FalModelImageSizeInput<TModel>\n }\n } else if (size.includes(':')) {\n return { aspect_ratio: size } as FalModelImageSizeInput<TModel>\n }\n }\n\n return {\n image_size: size,\n } as FalModelImageSizeInput<TModel>\n}\n"],"mappings":";AAEA,SAAgB,mBACd,MAC4C;CAC5C,IAAI,CAAC,MAAM,OAAO,KAAA;CAMlB,IAAI,OAAO,SAAS;MACd,KAAK,SAAS,GAAG,GAAG;GACtB,MAAM,CAAC,OAAO,UAAU,KAAK,MAAM,GAAG;GACtC,IAAI,SAAS,MAAM,SAAS,GAAG,GAC7B,OAAO;IACL,cAAc;IACd,YAAY;GACd;EAEJ,OAAO,IAAI,KAAK,SAAS,GAAG,GAC1B,OAAO,EAAE,cAAc,KAAK;CAAA;CAIhC,OAAO,EACL,YAAY,KACd;AACF"}
1
+ {"version":3,"file":"image-provider-options.js","names":[],"sources":["../../../src/image/image-provider-options.ts"],"sourcesContent":["import type { FalModelImageSize, FalModelImageSizeInput } from '../model-meta'\n\nexport function mapSizeToFalFormat<TModel extends string>(\n size: FalModelImageSize<TModel> | undefined,\n): FalModelImageSizeInput<TModel> | undefined {\n if (!size) return undefined\n\n // \"16:9_4K\" → { aspect_ratio, resolution }\n // \"16:9\" → { aspect_ratio }\n // \"4K\" → { resolution } (no colon, no underscore, model has `resolution`)\n // \"square_hd\" → { image_size } (no colon, no resolution field on model)\n if (typeof size === 'string') {\n if (size.includes('_')) {\n const [first, second] = size.split('_')\n if (first && first.includes(':')) {\n return {\n aspect_ratio: first,\n resolution: second,\n } as FalModelImageSizeInput<TModel>\n }\n } else if (size.includes(':')) {\n return { aspect_ratio: size } as FalModelImageSizeInput<TModel>\n }\n }\n\n return {\n image_size: size,\n } as FalModelImageSizeInput<TModel>\n}\n"],"mappings":";AAEA,SAAgB,mBACd,MAC4C;CAC5C,IAAI,CAAC,MAAM,OAAO,KAAA;CAMlB,IAAI,OAAO,SAAS,UAAU;EAC5B,IAAI,KAAK,SAAS,GAAG,GAAG;GACtB,MAAM,CAAC,OAAO,UAAU,KAAK,MAAM,GAAG;GACtC,IAAI,SAAS,MAAM,SAAS,GAAG,GAC7B,OAAO;IACL,cAAc;IACd,YAAY;GACd;EAEJ,OAAO,IAAI,KAAK,SAAS,GAAG,GAC1B,OAAO,EAAE,cAAc,KAAK;CAEhC;CAEA,OAAO,EACL,YAAY,KACd;AACF"}
@@ -19,9 +19,10 @@ export declare function takeBillableUnits(requestId: string | undefined): number
19
19
  /**
20
20
  * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
21
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.
22
+ * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined
23
+ * (its pricing page maps each endpoint to a unit price), so the count is opaque
24
+ * by design. Returns `undefined` when no units were captured so callers can
25
+ * omit `usage` entirely.
25
26
  */
26
27
  export declare function buildFalUsage(unitsBilled: number | undefined): TokenUsage | undefined;
27
28
  /**
@@ -59,9 +59,10 @@ function takeBillableUnits(requestId) {
59
59
  /**
60
60
  * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
61
61
  * no tokens, so the token fields are zero and the real billing signal rides on
62
- * `unitsBilled` — mirroring how the duration-billed transcription adapters
63
- * surface `durationSeconds`. Returns `undefined` when no units were captured so
64
- * callers can omit `usage` entirely.
62
+ * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined
63
+ * (its pricing page maps each endpoint to a unit price), so the count is opaque
64
+ * by design. Returns `undefined` when no units were captured so callers can
65
+ * omit `usage` entirely.
65
66
  */
66
67
  function buildFalUsage(unitsBilled) {
67
68
  if (unitsBilled == null) return void 0;
@@ -69,6 +70,10 @@ function buildFalUsage(unitsBilled) {
69
70
  promptTokens: 0,
70
71
  completionTokens: 0,
71
72
  totalTokens: 0,
73
+ billed: {
74
+ quantity: unitsBilled,
75
+ unit: "units"
76
+ },
72
77
  unitsBilled
73
78
  };
74
79
  }
@@ -1 +1 @@
1
- {"version":3,"file":"billing.js","names":[],"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"],"mappings":";;;;;AAMA,IAAM,4BAA4B;;;;;;;AAQlC,IAAM,wBAAwB;;;;;;;;AAS9B,IAAM,sBAAsB;AAE5B,IAAM,2CAA2B,IAAI,IAAoB;;;;;;AAOzD,SAAgB,mBAAmB,OAA0C;CAC3E,IAAI,SAAS,QAAQ,UAAU,IAAI,OAAO,KAAA;CAC1C,MAAM,SAAS,OAAO,KAAK;CAC3B,OAAO,OAAO,SAAS,MAAM,IAAI,SAAS,KAAA;AAC5C;;;;;;AAOA,SAAgB,gCAAgC,UAA0B;CACxE,MAAM,QAAQ,mBACZ,SAAS,QAAQ,IAAI,yBAAyB,CAChD;CACA,IAAI,SAAS,MAAM;CACnB,MAAM,YAAY,SAAS,QAAQ,IAAI,qBAAqB;CAC5D,IAAI,CAAC,WAAW;CAChB,IACE,yBAAyB,QAAQ,uBACjC,CAAC,yBAAyB,IAAI,SAAS,GACvC;EACA,MAAM,SAAS,yBAAyB,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;EACtD,IAAI,WAAW,KAAA,GAAW,yBAAyB,OAAO,MAAM;CAClE;CACA,yBAAyB,IAAI,WAAW,KAAK;AAC/C;;;;;AAMA,SAAgB,kBACd,WACoB;CACpB,IAAI,CAAC,WAAW,OAAO,KAAA;CACvB,MAAM,QAAQ,yBAAyB,IAAI,SAAS;CACpD,IAAI,UAAU,KAAA,GAAW,yBAAyB,OAAO,SAAS;CAClE,OAAO;AACT;;;;;;;;AASA,SAAgB,cACd,aACwB;CACxB,IAAI,eAAe,MAAM,OAAO,KAAA;CAChC,OAAO;EACL,cAAc;EACd,kBAAkB;EAClB,aAAa;EACb;CACF;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,YAA0B,WAAW,OACvB;CACd,OAAO,OAAO,OAAO,SAAS;EAC5B,MAAM,WAAW,MAAM,UAAU,OAAO,IAAI;EAC5C,IAAI;GACF,gCAAgC,QAAQ;EAC1C,QAAQ,CAER;EACA,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"billing.js","names":[],"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 * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined\n * (its pricing page maps each endpoint to a unit price), so the count is opaque\n * by design. Returns `undefined` when no units were captured so callers can\n * 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 billed: { quantity: unitsBilled, unit: 'units' },\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"],"mappings":";;;;;AAMA,IAAM,4BAA4B;;;;;;;AAQlC,IAAM,wBAAwB;;;;;;;;AAS9B,IAAM,sBAAsB;AAE5B,IAAM,2CAA2B,IAAI,IAAoB;;;;;;AAOzD,SAAgB,mBAAmB,OAA0C;CAC3E,IAAI,SAAS,QAAQ,UAAU,IAAI,OAAO,KAAA;CAC1C,MAAM,SAAS,OAAO,KAAK;CAC3B,OAAO,OAAO,SAAS,MAAM,IAAI,SAAS,KAAA;AAC5C;;;;;;AAOA,SAAgB,gCAAgC,UAA0B;CACxE,MAAM,QAAQ,mBACZ,SAAS,QAAQ,IAAI,yBAAyB,CAChD;CACA,IAAI,SAAS,MAAM;CACnB,MAAM,YAAY,SAAS,QAAQ,IAAI,qBAAqB;CAC5D,IAAI,CAAC,WAAW;CAChB,IACE,yBAAyB,QAAQ,uBACjC,CAAC,yBAAyB,IAAI,SAAS,GACvC;EACA,MAAM,SAAS,yBAAyB,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;EACtD,IAAI,WAAW,KAAA,GAAW,yBAAyB,OAAO,MAAM;CAClE;CACA,yBAAyB,IAAI,WAAW,KAAK;AAC/C;;;;;AAMA,SAAgB,kBACd,WACoB;CACpB,IAAI,CAAC,WAAW,OAAO,KAAA;CACvB,MAAM,QAAQ,yBAAyB,IAAI,SAAS;CACpD,IAAI,UAAU,KAAA,GAAW,yBAAyB,OAAO,SAAS;CAClE,OAAO;AACT;;;;;;;;;AAUA,SAAgB,cACd,aACwB;CACxB,IAAI,eAAe,MAAM,OAAO,KAAA;CAChC,OAAO;EACL,cAAc;EACd,kBAAkB;EAClB,aAAa;EACb,QAAQ;GAAE,UAAU;GAAa,MAAM;EAAQ;EAC/C;CACF;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,YAA0B,WAAW,OACvB;CACd,OAAO,OAAO,OAAO,SAAS;EAC5B,MAAM,WAAW,MAAM,UAAU,OAAO,IAAI;EAC5C,IAAI;GACF,gCAAgC,QAAQ;EAC1C,QAAQ,CAER;EACA,OAAO;CACT;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-fal",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "fal.ai adapter for TanStack AI image, video, audio, speech, and transcription generation.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -52,12 +52,12 @@
52
52
  "@tanstack/ai-utils": "^0.4.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@vitest/coverage-v8": "4.0.14",
56
- "vite": "^8.1.4",
57
- "@tanstack/ai": "0.44.0"
55
+ "@vitest/coverage-v8": "4.1.10",
56
+ "vite": "^8.2.1",
57
+ "@tanstack/ai": "0.46.0"
58
58
  },
59
59
  "peerDependencies": {
60
- "@tanstack/ai": "^0.44.0"
60
+ "@tanstack/ai": "^0.46.0"
61
61
  },
62
62
  "scripts": {
63
63
  "build": "vite build",
@@ -74,9 +74,10 @@ export function takeBillableUnits(
74
74
  /**
75
75
  * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
76
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.
77
+ * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined
78
+ * (its pricing page maps each endpoint to a unit price), so the count is opaque
79
+ * by design. Returns `undefined` when no units were captured so callers can
80
+ * omit `usage` entirely.
80
81
  */
81
82
  export function buildFalUsage(
82
83
  unitsBilled: number | undefined,
@@ -86,6 +87,7 @@ export function buildFalUsage(
86
87
  promptTokens: 0,
87
88
  completionTokens: 0,
88
89
  totalTokens: 0,
90
+ billed: { quantity: unitsBilled, unit: 'units' },
89
91
  unitsBilled,
90
92
  }
91
93
  }