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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/ai-fal/tests/speech-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { generateSpeech } from '@tanstack/ai'

import { falSpeech } from '../src/adapters/speech'
import { recordBillableUnitsFromResponse } from '../src/utils/billing'

function seedBillableUnits(requestId: string, units: string) {
recordBillableUnitsFromResponse(
new Response(null, {
headers: {
'x-fal-request-id': requestId,
'x-fal-billable-units': units,
},
}),
)
}

// Declare mocks at module level
let mockSubscribe: any
Expand Down Expand Up @@ -303,4 +315,50 @@ describe('Fal Speech Adapter', () => {
// URL has no extension and no content-type — default to wav.
expect(result.format).toBe('wav')
})

it('surfaces fal billable units as usage', async () => {
seedBillableUnits('req-billed-speech', '3')
mockSubscribe.mockResolvedValueOnce({
data: {
audio: {
url: 'https://fal.media/files/billed.wav',
content_type: 'audio/wav',
},
},
requestId: 'req-billed-speech',
})

const result = await generateSpeech({
adapter: createAdapter(),
text: 'billed speech',
modelOptions: { audio_url: REFERENCE_AUDIO },
})

expect(result.usage).toEqual({
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
unitsBilled: 3,
})
})

it('omits usage when fal does not report billable units', async () => {
mockSubscribe.mockResolvedValueOnce({
data: {
audio: {
url: 'https://fal.media/files/unbilled.wav',
content_type: 'audio/wav',
},
},
requestId: 'req-unbilled-speech',
})

const result = await generateSpeech({
adapter: createAdapter(),
text: 'unbilled speech',
modelOptions: { audio_url: REFERENCE_AUDIO },
})

expect(result.usage).toBeUndefined()
})
})
46 changes: 46 additions & 0 deletions packages/ai-fal/tests/transcription-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { generateTranscription } from '@tanstack/ai'

import { falTranscription } from '../src/adapters/transcription'
import { recordBillableUnitsFromResponse } from '../src/utils/billing'

function seedBillableUnits(requestId: string, units: string) {
recordBillableUnitsFromResponse(
new Response(null, {
headers: {
'x-fal-request-id': requestId,
'x-fal-billable-units': units,
},
}),
)
}

// Declare mocks at module level
let mockSubscribe: any
Expand Down Expand Up @@ -292,4 +304,38 @@ describe('Fal Transcription Adapter', () => {
expect(result.segments).toHaveLength(1)
expect(result.segments![0]!.text).toBe('Only.')
})

it('surfaces fal billable units as usage', async () => {
seedBillableUnits('req-billed-transcription', '1.5')
mockSubscribe.mockResolvedValueOnce({
data: { text: 'Billed transcription.' },
requestId: 'req-billed-transcription',
})

const result = await generateTranscription({
adapter: createAdapter(),
audio: 'https://example.com/audio.mp3',
})

expect(result.usage).toEqual({
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
unitsBilled: 1.5,
})
})

it('omits usage when fal does not report billable units', async () => {
mockSubscribe.mockResolvedValueOnce({
data: { text: 'Unbilled transcription.' },
requestId: 'req-unbilled-transcription',
})

const result = await generateTranscription({
adapter: createAdapter(),
audio: 'https://example.com/audio.mp3',
})

expect(result.usage).toBeUndefined()
})
})
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 0 additions & 85 deletions testing/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,6 @@ export default async function globalSetup() {
// `promptTokensDetails.cachedTokens` / `completionTokensDetails.reasoningTokens`.
mock.mount('/openai-usage-details', openaiUsageDetailsMount())

// fal billable-units capture. aimock doesn't model fal's queue protocol
// (submit → poll status → fetch result) or its `x-fal-billable-units` /
// `x-fal-request-id` result headers, so this mount hand-rolls the three queue
// round-trips and stamps the billing headers on the result fetch. The
// companion api.fal-billable-units route redirects fal's hardcoded
// queue.fal.run URLs here and asserts the units reach `result.usage`.
mock.mount('/fal-queue', falQueueMount())

await mock.start()
console.log(`[aimock] started on port 4010`)
;(globalThis as any).__aimock = mock
Expand Down Expand Up @@ -474,83 +466,6 @@ function openaiUsageDetailsMount(): Mountable {
}
}

/**
* Request id and billed quantity the fal queue mount reports. Exported-by-value
* to the companion route/spec via the literal below — kept in one place so the
* assertion and the mock can't drift.
*/
const FAL_E2E_REQUEST_ID = 'fal-req-e2e'
const FAL_E2E_BILLABLE_UNITS = '4'

/**
* Mimics fal's queue protocol for a single image generation:
* POST /{appId} → submit, returns request_id
* GET /{appId}/requests/{id}/status → poll, returns COMPLETED
* GET /{appId}/requests/{id} → result, returns the image payload
* with `x-fal-request-id` and
* `x-fal-billable-units` headers
* The billing fetch installed by @tanstack/ai-fal reads those headers off the
* result fetch and the adapter surfaces them as `result.usage.unitsBilled`.
*/
function falQueueMount(): Mountable {
return {
async handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
// Mount prefix (/fal-queue) is stripped; pathname is `/{appId}/...`.
pathname: string,
): Promise<boolean> {
const isResultPath =
req.method === 'GET' && /\/requests\/[^/]+$/.test(pathname)
const isStatusPath = req.method === 'GET' && pathname.endsWith('/status')
const isSubmitPath =
req.method === 'POST' && !pathname.includes('/requests/')

if (isSubmitPath) {
await drainBody(req)
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(
JSON.stringify({
request_id: FAL_E2E_REQUEST_ID,
status: 'IN_QUEUE',
}),
)
return true
}

if (isStatusPath) {
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(
JSON.stringify({
status: 'COMPLETED',
request_id: FAL_E2E_REQUEST_ID,
}),
)
return true
}

if (isResultPath) {
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
// The two headers the feature hangs on: the billed quantity, and the
// request id the adapter correlates it against.
res.setHeader('x-fal-request-id', FAL_E2E_REQUEST_ID)
res.setHeader('x-fal-billable-units', FAL_E2E_BILLABLE_UNITS)
res.end(
JSON.stringify({
images: [{ url: 'https://fal.media/files/e2e-billed.png' }],
}),
)
return true
}

return false
},
}
}

function buildToolPlusServerToolEvents(): Array<Record<string, unknown>> {
const messageId = 'msg_bug_604'
const model = 'claude-sonnet-4-5'
Expand Down
1 change: 0 additions & 1 deletion testing/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
"@tanstack/ai-anthropic": "workspace:*",
"@tanstack/ai-client": "workspace:*",
"@tanstack/ai-elevenlabs": "workspace:*",
"@tanstack/ai-fal": "workspace:*",
"@tanstack/ai-gemini": "workspace:*",
"@tanstack/ai-grok": "workspace:*",
"@tanstack/ai-groq": "workspace:*",
Expand Down
21 changes: 0 additions & 21 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import { Route as ApiMcpServerRouteImport } from './routes/api.mcp-server'
import { Route as ApiMcpManagedTestRouteImport } from './routes/api.mcp-managed-test'
import { Route as ApiMcpLifecycleTestRouteImport } from './routes/api.mcp-lifecycle-test'
import { Route as ApiImageRouteImport } from './routes/api.image'
import { Route as ApiFalBillableUnitsRouteImport } from './routes/api.fal-billable-units'
import { Route as ApiChatRouteImport } from './routes/api.chat'
import { Route as ApiAudioRouteImport } from './routes/api.audio'
import { Route as ApiArktypeToolWireRouteImport } from './routes/api.arktype-tool-wire'
Expand Down Expand Up @@ -193,11 +192,6 @@ const ApiImageRoute = ApiImageRouteImport.update({
path: '/api/image',
getParentRoute: () => rootRouteImport,
} as any)
const ApiFalBillableUnitsRoute = ApiFalBillableUnitsRouteImport.update({
id: '/api/fal-billable-units',
path: '/api/fal-billable-units',
getParentRoute: () => rootRouteImport,
} as any)
const ApiChatRoute = ApiChatRouteImport.update({
id: '/api/chat',
path: '/api/chat',
Expand Down Expand Up @@ -271,7 +265,6 @@ export interface FileRoutesByFullPath {
'/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute
'/api/audio': typeof ApiAudioRouteWithChildren
'/api/chat': typeof ApiChatRoute
'/api/fal-billable-units': typeof ApiFalBillableUnitsRoute
'/api/image': typeof ApiImageRouteWithChildren
'/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute
'/api/mcp-managed-test': typeof ApiMcpManagedTestRoute
Expand Down Expand Up @@ -313,7 +306,6 @@ export interface FileRoutesByTo {
'/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute
'/api/audio': typeof ApiAudioRouteWithChildren
'/api/chat': typeof ApiChatRoute
'/api/fal-billable-units': typeof ApiFalBillableUnitsRoute
'/api/image': typeof ApiImageRouteWithChildren
'/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute
'/api/mcp-managed-test': typeof ApiMcpManagedTestRoute
Expand Down Expand Up @@ -356,7 +348,6 @@ export interface FileRoutesById {
'/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute
'/api/audio': typeof ApiAudioRouteWithChildren
'/api/chat': typeof ApiChatRoute
'/api/fal-billable-units': typeof ApiFalBillableUnitsRoute
'/api/image': typeof ApiImageRouteWithChildren
'/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute
'/api/mcp-managed-test': typeof ApiMcpManagedTestRoute
Expand Down Expand Up @@ -400,7 +391,6 @@ export interface FileRouteTypes {
| '/api/arktype-tool-wire'
| '/api/audio'
| '/api/chat'
| '/api/fal-billable-units'
| '/api/image'
| '/api/mcp-lifecycle-test'
| '/api/mcp-managed-test'
Expand Down Expand Up @@ -442,7 +432,6 @@ export interface FileRouteTypes {
| '/api/arktype-tool-wire'
| '/api/audio'
| '/api/chat'
| '/api/fal-billable-units'
| '/api/image'
| '/api/mcp-lifecycle-test'
| '/api/mcp-managed-test'
Expand Down Expand Up @@ -484,7 +473,6 @@ export interface FileRouteTypes {
| '/api/arktype-tool-wire'
| '/api/audio'
| '/api/chat'
| '/api/fal-billable-units'
| '/api/image'
| '/api/mcp-lifecycle-test'
| '/api/mcp-managed-test'
Expand Down Expand Up @@ -527,7 +515,6 @@ export interface RootRouteChildren {
ApiArktypeToolWireRoute: typeof ApiArktypeToolWireRoute
ApiAudioRoute: typeof ApiAudioRouteWithChildren
ApiChatRoute: typeof ApiChatRoute
ApiFalBillableUnitsRoute: typeof ApiFalBillableUnitsRoute
ApiImageRoute: typeof ApiImageRouteWithChildren
ApiMcpLifecycleTestRoute: typeof ApiMcpLifecycleTestRoute
ApiMcpManagedTestRoute: typeof ApiMcpManagedTestRoute
Expand Down Expand Up @@ -746,13 +733,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiImageRouteImport
parentRoute: typeof rootRouteImport
}
'/api/fal-billable-units': {
id: '/api/fal-billable-units'
path: '/api/fal-billable-units'
fullPath: '/api/fal-billable-units'
preLoaderRoute: typeof ApiFalBillableUnitsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/chat': {
id: '/api/chat'
path: '/api/chat'
Expand Down Expand Up @@ -908,7 +888,6 @@ const rootRouteChildren: RootRouteChildren = {
ApiArktypeToolWireRoute: ApiArktypeToolWireRoute,
ApiAudioRoute: ApiAudioRouteWithChildren,
ApiChatRoute: ApiChatRoute,
ApiFalBillableUnitsRoute: ApiFalBillableUnitsRoute,
ApiImageRoute: ApiImageRouteWithChildren,
ApiMcpLifecycleTestRoute: ApiMcpLifecycleTestRoute,
ApiMcpManagedTestRoute: ApiMcpManagedTestRoute,
Expand Down
Loading
Loading