Skip to content

Commit 2441e8d

Browse files
authored
fix(billing): settle hosted fal generations to their exact cost (#9750)
* fix(billing): settle hosted fal generations to their exact cost * fix(billing): settle into the payer's current period and cancel on trusted fal URLs * fix(billing): settle zero fal estimates and fix the settlement fixture payer
1 parent 89c1d63 commit 2441e8d

20 files changed

Lines changed: 822 additions & 209 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: 11 additions & 3 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,12 +121,16 @@ 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
})
128132

129-
it('rejects foreign fal queue hosts before sending credentials', async () => {
133+
it('rejects foreign fal queue hosts before sending credentials and cancels on the fal queue', async () => {
130134
mocks.fetch.mockResolvedValueOnce(
131135
Response.json({
132136
request_id: 'foreign-job',
@@ -143,6 +147,10 @@ describe('image operations', () => {
143147
expect(response.status).toBe(500)
144148
expect(await response.json()).toMatchObject({ error: 'Invalid Fal.ai queue URL' })
145149
expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
150+
expect(mocks.fetch).toHaveBeenLastCalledWith(
151+
expect.stringMatching(/^https:\/\/queue\.fal\.run\/.+\/requests\/foreign-job\/cancel$/),
152+
expect.objectContaining({ method: 'PUT' })
153+
)
146154
})
147155

148156
it('retrieves nested fal jobs from the documented application request path', async () => {

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

Lines changed: 124 additions & 108 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'
@@ -456,134 +457,149 @@ async function generateWithFalAI(
456457
throw new Error('Fal.ai queue response missing request_id')
457458
}
458459

459-
const statusUrl = falQueueUrl(
460-
getStringProperty(createData, 'status_url') ||
461-
buildFalAIQueueUrl(endpoint, falRequestId, 'status')
462-
)
463-
const responseUrl = falQueueUrl(
464-
getStringProperty(createData, 'response_url') ||
465-
buildFalAIQueueUrl(endpoint, falRequestId, 'response')
466-
)
460+
/** Built from the endpoint, never a provider URL, so a rejected one can still be cancelled. */
461+
const cancelUrl = `${buildFalAIQueueUrl(endpoint, falRequestId, 'response')}/cancel`
467462

468463
logger.info('Fal.ai image request created', { requestId, falRequestId })
469464

470-
const statusValidation = await validateUrlWithDNS(statusUrl, 'statusUrl', 'contentFetch')
471-
if (!statusValidation.isValid) {
472-
throw new Error(statusValidation.error)
473-
}
474-
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-
})
465+
let reachedTerminalStatus = false
466+
try {
467+
const statusUrl = falQueueUrl(
468+
getStringProperty(createData, 'status_url') ||
469+
buildFalAIQueueUrl(endpoint, falRequestId, 'status')
470+
)
471+
const responseUrl = falQueueUrl(
472+
getStringProperty(createData, 'response_url') ||
473+
buildFalAIQueueUrl(endpoint, falRequestId, 'response')
474+
)
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+
const statusValidation = await validateUrlWithDNS(statusUrl, 'statusUrl', 'contentFetch')
477+
if (!statusValidation.isValid) {
478+
throw new Error(statusValidation.error)
500479
}
501480

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-
}
481+
const pollIntervalMs = 3000
482+
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
483+
let attempts = 0
510484

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,
485+
while (attempts < maxAttempts) {
486+
await interruptibleSleep(pollIntervalMs, signal)
487+
signal?.throwIfAborted()
488+
489+
const statusResponse = await secureFetchWithPinnedIP(statusUrl, statusValidation.resolvedIP, {
490+
profile: 'contentFetch',
491+
maxRedirects: 0,
492+
headers: {
493+
Authorization: `Key ${apiKey}`,
494+
},
518495
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
519-
trackCost: body.useHostedCostTracking,
520-
onCost: onFalCost,
521496
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-
},
536497
})
537498

538-
if (!isRecordLike(resultData)) {
539-
throw new Error('Invalid Fal.ai result response')
499+
if (!statusResponse.ok) {
500+
await readResponseTextWithLimit(statusResponse, {
501+
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
502+
label: 'Fal.ai status error response',
503+
signal,
504+
}).catch(() => '')
505+
throw new Error(`Fal.ai status check failed: ${statusResponse.status}`)
540506
}
541507

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')
508+
const statusData = await readResponseJsonWithLimit(statusResponse, {
509+
maxBytes: MAX_IMAGE_JSON_BYTES,
510+
label: 'Fal.ai status response',
511+
signal,
512+
})
513+
if (!isRecordLike(statusData)) {
514+
throw new Error('Invalid Fal.ai status response')
552515
}
553516

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

579-
if (['ERROR', 'FAILED', 'CANCELLED'].includes(status || '')) {
580-
throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusData.error)}`)
590+
if (['ERROR', 'FAILED', 'CANCELLED'].includes(status || '')) {
591+
reachedTerminalStatus = true
592+
throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusData.error)}`)
593+
}
594+
595+
attempts += 1
581596
}
582597

583-
attempts += 1
598+
throw new Error('Fal.ai image generation timed out')
599+
} catch (error) {
600+
if (!reachedTerminalStatus) await cancelFalRequest(cancelUrl, apiKey, falRequestId)
601+
throw error
584602
}
585-
586-
throw new Error('Fal.ai image generation timed out')
587603
}
588604

589605
async function storeGeneratedImage(

0 commit comments

Comments
 (0)