Skip to content

Commit 0dec7f3

Browse files
committed
fix(billing): settle hosted fal generations to their exact cost
1 parent d22623b commit 0dec7f3

19 files changed

Lines changed: 688 additions & 197 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createLogger } from '@sim/logger'
2+
import { task } from '@trigger.dev/sdk'
3+
import {
4+
FALAI_COST_SETTLEMENT_TASK_ID,
5+
type FalAICostSettlementPayload,
6+
settleFalAICost,
7+
} from '@/lib/tools/falai-settlement'
8+
9+
const logger = createLogger('FalAICostSettlementTask')
10+
11+
/**
12+
* Settles a hosted fal generation billed on an estimate to fal's exact charge. A receipt fal
13+
* has not recorded yet fails the attempt so the retries keep looking for about an hour; past
14+
* that the estimate stands.
15+
*/
16+
export const falaiCostSettlementTask = task({
17+
id: FALAI_COST_SETTLEMENT_TASK_ID,
18+
retry: {
19+
maxAttempts: 8,
20+
factor: 2,
21+
minTimeoutInMs: 60_000,
22+
maxTimeoutInMs: 15 * 60_000,
23+
},
24+
queue: { concurrencyLimit: 5 },
25+
run: async (payload: FalAICostSettlementPayload) => {
26+
const outcome = await settleFalAICost(payload)
27+
if (outcome === 'pending') {
28+
throw new Error(`fal has not recorded the charge for request ${payload.requestId} yet`)
29+
}
30+
logger.info('fal cost settlement finished', {
31+
requestId: payload.requestId,
32+
endpointId: payload.endpointId,
33+
outcome,
34+
})
35+
return { outcome }
36+
},
37+
})

‎apps/sim/lib/internal/image/operations.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ describe('image operations', () => {
102102
])
103103
})
104104

105-
it('cancels polling without resubmitting or storing an image', async () => {
105+
it('cancels the fal request when polling stops, without resubmitting or storing an image', async () => {
106106
const controller = new AbortController()
107107
mocks.fetch.mockResolvedValueOnce(
108108
Response.json({
@@ -121,7 +121,11 @@ describe('image operations', () => {
121121
})
122122
).rejects.toMatchObject({ name: 'AbortError' })
123123

124-
expect(mocks.fetch).toHaveBeenCalledTimes(1)
124+
expect(mocks.fetch).toHaveBeenCalledTimes(2)
125+
expect(mocks.fetch).toHaveBeenLastCalledWith(
126+
expect.stringMatching(/^https:\/\/queue\.fal\.run\/.+\/requests\/job-2\/cancel$/),
127+
expect.objectContaining({ method: 'PUT' })
128+
)
125129
expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
126130
expect(mockUploadCopilotFile).not.toHaveBeenCalled()
127131
})

‎apps/sim/lib/internal/image/operations.ts‎

Lines changed: 119 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { MAX_REMOTE_IMAGE_BYTES } from '@/lib/internal/image/fetch'
2020
import { buildImageGenerationRequest } from '@/lib/internal/image/request'
2121
import type { ImageGenerationInput, ImageProvider } from '@/lib/internal/image/schema'
22+
import { cancelFalRequest } from '@/lib/media/falai'
2223
import { FalAIResultError, fetchFalAIResult } from '@/lib/media/falai-result'
2324
import { recordServiceMeteringFailure } from '@/lib/mothership/billing/service-observer'
2425
import type { FalAICostMetadata } from '@/lib/tools/falai-pricing'
@@ -465,125 +466,142 @@ async function generateWithFalAI(
465466
buildFalAIQueueUrl(endpoint, falRequestId, 'response')
466467
)
467468

468-
logger.info('Fal.ai image request created', { requestId, falRequestId })
469-
470-
const statusValidation = await validateUrlWithDNS(statusUrl, 'statusUrl', 'contentFetch')
471-
if (!statusValidation.isValid) {
472-
throw new Error(statusValidation.error)
473-
}
469+
const cancelUrl = falQueueUrl(
470+
getStringProperty(createData, 'cancel_url') ||
471+
`${buildFalAIQueueUrl(endpoint, falRequestId, 'response')}/cancel`
472+
)
474473

475-
const pollIntervalMs = 3000
476-
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
477-
let attempts = 0
478-
479-
while (attempts < maxAttempts) {
480-
await interruptibleSleep(pollIntervalMs, signal)
481-
signal?.throwIfAborted()
482-
483-
const statusResponse = await secureFetchWithPinnedIP(statusUrl, statusValidation.resolvedIP, {
484-
profile: 'contentFetch',
485-
maxRedirects: 0,
486-
headers: {
487-
Authorization: `Key ${apiKey}`,
488-
},
489-
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
490-
signal,
491-
})
474+
logger.info('Fal.ai image request created', { requestId, falRequestId })
492475

493-
if (!statusResponse.ok) {
494-
await readResponseTextWithLimit(statusResponse, {
495-
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
496-
label: 'Fal.ai status error response',
497-
signal,
498-
}).catch(() => '')
499-
throw new Error(`Fal.ai status check failed: ${statusResponse.status}`)
476+
let reachedTerminalStatus = false
477+
try {
478+
const statusValidation = await validateUrlWithDNS(statusUrl, 'statusUrl', 'contentFetch')
479+
if (!statusValidation.isValid) {
480+
throw new Error(statusValidation.error)
500481
}
501482

502-
const statusData = await readResponseJsonWithLimit(statusResponse, {
503-
maxBytes: MAX_IMAGE_JSON_BYTES,
504-
label: 'Fal.ai status response',
505-
signal,
506-
})
507-
if (!isRecordLike(statusData)) {
508-
throw new Error('Invalid Fal.ai status response')
509-
}
483+
const pollIntervalMs = 3000
484+
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
485+
let attempts = 0
510486

511-
const status = getStringProperty(statusData, 'status')
512-
if (status === 'COMPLETED') {
513-
const { data: resultData, cost: falaiCost } = await fetchFalAIResult({
514-
apiKey,
515-
endpointId: endpoint,
516-
requestId: falRequestId,
517-
queueError: statusData.error,
487+
while (attempts < maxAttempts) {
488+
await interruptibleSleep(pollIntervalMs, signal)
489+
signal?.throwIfAborted()
490+
491+
const statusResponse = await secureFetchWithPinnedIP(statusUrl, statusValidation.resolvedIP, {
492+
profile: 'contentFetch',
493+
maxRedirects: 0,
494+
headers: {
495+
Authorization: `Key ${apiKey}`,
496+
},
518497
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
519-
trackCost: body.useHostedCostTracking,
520-
onCost: onFalCost,
521498
signal,
522-
fetchResponse: async (resultSignal) => {
523-
const resultUrl = falQueueUrl(
524-
getStringProperty(statusData, 'response_url') || responseUrl
525-
)
526-
const resultValidation = await validateUrlWithDNS(resultUrl, 'resultUrl', 'contentFetch')
527-
if (!resultValidation.isValid) throw new Error(resultValidation.error)
528-
return secureFetchWithPinnedIP(resultUrl, resultValidation.resolvedIP, {
529-
profile: 'contentFetch',
530-
maxRedirects: 0,
531-
headers: { Authorization: `Key ${apiKey}` },
532-
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
533-
signal: resultSignal,
534-
})
535-
},
536499
})
537500

538-
if (!isRecordLike(resultData)) {
539-
throw new Error('Invalid Fal.ai result response')
501+
if (!statusResponse.ok) {
502+
await readResponseTextWithLimit(statusResponse, {
503+
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
504+
label: 'Fal.ai status error response',
505+
signal,
506+
}).catch(() => '')
507+
throw new Error(`Fal.ai status check failed: ${statusResponse.status}`)
508+
}
509+
510+
const statusData = await readResponseJsonWithLimit(statusResponse, {
511+
maxBytes: MAX_IMAGE_JSON_BYTES,
512+
label: 'Fal.ai status response',
513+
signal,
514+
})
515+
if (!isRecordLike(statusData)) {
516+
throw new Error('Invalid Fal.ai status response')
540517
}
541518

542-
const firstImage =
543-
prepared.model.output === 'image' && isRecordLike(resultData.image)
544-
? resultData.image
545-
: firstRecord(resultData.images)
546-
const imageUrl =
547-
getStringProperty(firstImage, 'url') ||
548-
getStringProperty(firstImage, 'data') ||
549-
getStringProperty(firstImage, 'content')
550-
if (!imageUrl) {
551-
throw new Error('No image URL in Fal.ai response')
519+
const status = getStringProperty(statusData, 'status')
520+
if (status === 'COMPLETED') {
521+
reachedTerminalStatus = true
522+
const { data: resultData, cost: falaiCost } = await fetchFalAIResult({
523+
apiKey,
524+
endpointId: endpoint,
525+
requestId: falRequestId,
526+
queueError: statusData.error,
527+
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
528+
trackCost: body.useHostedCostTracking,
529+
onCost: onFalCost,
530+
signal,
531+
fetchResponse: async (resultSignal) => {
532+
const resultUrl = falQueueUrl(
533+
getStringProperty(statusData, 'response_url') || responseUrl
534+
)
535+
const resultValidation = await validateUrlWithDNS(
536+
resultUrl,
537+
'resultUrl',
538+
'contentFetch'
539+
)
540+
if (!resultValidation.isValid) throw new Error(resultValidation.error)
541+
return secureFetchWithPinnedIP(resultUrl, resultValidation.resolvedIP, {
542+
profile: 'contentFetch',
543+
maxRedirects: 0,
544+
headers: { Authorization: `Key ${apiKey}` },
545+
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
546+
signal: resultSignal,
547+
})
548+
},
549+
})
550+
551+
if (!isRecordLike(resultData)) {
552+
throw new Error('Invalid Fal.ai result response')
553+
}
554+
555+
const firstImage =
556+
prepared.model.output === 'image' && isRecordLike(resultData.image)
557+
? resultData.image
558+
: firstRecord(resultData.images)
559+
const imageUrl =
560+
getStringProperty(firstImage, 'url') ||
561+
getStringProperty(firstImage, 'data') ||
562+
getStringProperty(firstImage, 'content')
563+
if (!imageUrl) {
564+
throw new Error('No image URL in Fal.ai response')
565+
}
566+
567+
const downloaded = await bufferFromImageUrl(imageUrl, signal)
568+
const contentType =
569+
getStringProperty(firstImage, 'content_type') ||
570+
getStringProperty(firstImage, 'contentType') ||
571+
downloaded.contentType
572+
const fileName =
573+
getStringProperty(firstImage, 'file_name') ||
574+
getStringProperty(firstImage, 'fileName') ||
575+
`falai-${model}.${extensionFromContentType(contentType)}`
576+
577+
return {
578+
buffer: downloaded.buffer,
579+
contentType,
580+
fileName,
581+
provider: 'falai',
582+
model,
583+
sourceUrl: imageUrl.startsWith('data:') ? undefined : imageUrl,
584+
description: getStringProperty(resultData, 'description'),
585+
revisedPrompt: getStringProperty(resultData, 'revised_prompt'),
586+
seed: getNumberProperty(resultData, 'seed'),
587+
jobId: falRequestId,
588+
falaiCost,
589+
}
552590
}
553591

554-
const downloaded = await bufferFromImageUrl(imageUrl, signal)
555-
const contentType =
556-
getStringProperty(firstImage, 'content_type') ||
557-
getStringProperty(firstImage, 'contentType') ||
558-
downloaded.contentType
559-
const fileName =
560-
getStringProperty(firstImage, 'file_name') ||
561-
getStringProperty(firstImage, 'fileName') ||
562-
`falai-${model}.${extensionFromContentType(contentType)}`
563-
564-
return {
565-
buffer: downloaded.buffer,
566-
contentType,
567-
fileName,
568-
provider: 'falai',
569-
model,
570-
sourceUrl: imageUrl.startsWith('data:') ? undefined : imageUrl,
571-
description: getStringProperty(resultData, 'description'),
572-
revisedPrompt: getStringProperty(resultData, 'revised_prompt'),
573-
seed: getNumberProperty(resultData, 'seed'),
574-
jobId: falRequestId,
575-
falaiCost,
592+
if (['ERROR', 'FAILED', 'CANCELLED'].includes(status || '')) {
593+
reachedTerminalStatus = true
594+
throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusData.error)}`)
576595
}
577-
}
578596

579-
if (['ERROR', 'FAILED', 'CANCELLED'].includes(status || '')) {
580-
throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusData.error)}`)
597+
attempts += 1
581598
}
582599

583-
attempts += 1
600+
throw new Error('Fal.ai image generation timed out')
601+
} catch (error) {
602+
if (!reachedTerminalStatus) await cancelFalRequest(cancelUrl, apiKey, falRequestId)
603+
throw error
584604
}
585-
586-
throw new Error('Fal.ai image generation timed out')
587605
}
588606

589607
async function storeGeneratedImage(

0 commit comments

Comments
 (0)