Skip to content

Commit 8e3df8a

Browse files
authored
fix(falai): bound pricing lookups and preserve result receipts (#9706)
Bound and coalesce account-specific fal pricing lookups, honor Retry-After cooldowns, and validate exact receipt identity. Preserve exact receipts through cancellation, terminal errors, and stalled retrieval while distinguishing explicit generation failures from transport failures. Keep error output bounded and redacted, and support generated images up to the existing buffered-transfer limit. Validated with focused regressions, the full repository suite, type checks, lint, audits, and CI.
1 parent a87c413 commit 8e3df8a

10 files changed

Lines changed: 996 additions & 156 deletions

File tree

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

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ import {
66
inputValidationMock,
77
inputValidationMockFns,
88
} from '@sim/testing/mocks/input-validation.mock'
9+
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
910
import { uploadsCopilotMock, uploadsCopilotMockFns } from '@sim/testing/mocks/uploads-copilot.mock'
1011
import { uploadsExecutionMock } from '@sim/testing/mocks/uploads-execution.mock'
1112
import { utilsHelpersMock, utilsHelpersMockFns } from '@sim/testing/mocks/utils-helpers.mock'
1213
import { beforeEach, describe, expect, it, vi } from 'vitest'
1314

1415
const mocks = vi.hoisted(() => ({
1516
fetch: vi.fn(),
16-
getFalAICostMetadata: vi.fn(),
17+
getFalAIEstimatedCostMetadata: vi.fn(),
18+
getFalAIBillingReceipt: vi.fn(),
1719
}))
1820

1921
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
@@ -23,7 +25,9 @@ vi.mock('@sim/utils/helpers', () => utilsHelpersMock)
2325
vi.mock('@/lib/core/execution-limits', () => executionLimitsMock)
2426

2527
vi.mock('@/lib/tools/falai-pricing', () => ({
26-
getFalAICostMetadata: mocks.getFalAICostMetadata,
28+
FALAI_COST_LOOKUP_TIMEOUT_MS: 10_000,
29+
getFalAIEstimatedCostMetadata: mocks.getFalAIEstimatedCostMetadata,
30+
getFalAIBillingReceipt: mocks.getFalAIBillingReceipt,
2731
}))
2832

2933
vi.mock('@/lib/uploads/contexts/copilot', () => uploadsCopilotMock)
@@ -33,6 +37,7 @@ vi.mock('@/lib/uploads/contexts/execution', () => uploadsExecutionMock)
3337
const { mockSecureFetchWithPinnedIP, mockValidateUrlWithDNS } = inputValidationMockFns
3438

3539
import { executeImageGeneration } from '@/lib/internal/image/operations'
40+
import { observeServiceCosts } from '@/lib/mothership/billing/service-observer'
3641

3742
const { mockInterruptibleSleep } = utilsHelpersMockFns
3843

@@ -50,6 +55,8 @@ const falInput = {
5055
describe('image operations', () => {
5156
beforeEach(() => {
5257
mocks.fetch.mockReset()
58+
mocks.getFalAIEstimatedCostMetadata.mockReset()
59+
mocks.getFalAIBillingReceipt.mockReset().mockResolvedValue(undefined)
5360
mockSecureFetchWithPinnedIP.mockReset()
5461
vi.stubGlobal('fetch', mocks.fetch)
5562
mockInterruptibleSleep.mockResolvedValue(undefined)
@@ -182,15 +189,17 @@ describe('image operations', () => {
182189
it('retains the completed fal receipt when delivery is interrupted', async () => {
183190
const controller = new AbortController()
184191
mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'paid-job' }))
185-
mocks.getFalAICostMetadata.mockImplementation(async ({ signal }: { signal?: AbortSignal }) => {
186-
signal?.throwIfAborted()
187-
return {
188-
requestId: 'paid-job',
189-
endpointId: 'fal-ai/nano-banana-2',
190-
costDollars: 0.1,
191-
source: 'billing_events',
192+
mocks.getFalAIBillingReceipt.mockImplementation(
193+
async ({ signal }: { signal?: AbortSignal }) => {
194+
signal?.throwIfAborted()
195+
return {
196+
requestId: 'paid-job',
197+
endpointId: 'fal-ai/nano-banana-2',
198+
costDollars: 0.1,
199+
source: 'billing_events',
200+
}
192201
}
193-
})
202+
)
194203
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(Response.json({ status: 'COMPLETED' }))
195204
mockSecureFetchWithPinnedIP.mockImplementationOnce(async () => {
196205
controller.abort()
@@ -207,9 +216,130 @@ describe('image operations', () => {
207216
}
208217
)
209218
).rejects.toMatchObject({ name: 'AbortError' })
210-
expect(mocks.getFalAICostMetadata).toHaveBeenCalledWith(
219+
expect(mocks.getFalAIBillingReceipt).toHaveBeenCalledWith(
211220
expect.objectContaining({ requestId: 'paid-job' })
212221
)
213-
expect(mocks.getFalAICostMetadata.mock.calls[0][0].signal).toBeUndefined()
222+
expect(mocks.getFalAIBillingReceipt.mock.calls[0][0].signal).toBeUndefined()
223+
})
224+
it.each([422, 500])(
225+
'reports HTTP %i generation failures without speculative delivery receipts',
226+
async (status) => {
227+
mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'rejected-job' }))
228+
mockSecureFetchWithPinnedIP
229+
.mockResolvedValueOnce(Response.json({ status: 'COMPLETED' }))
230+
.mockResolvedValueOnce(
231+
Response.json(
232+
{
233+
detail: [
234+
{ type: 'no_media_generated', msg: 'secret-prompt', input: 'secret-prompt' },
235+
],
236+
},
237+
{ status }
238+
)
239+
)
240+
mocks.getFalAIEstimatedCostMetadata.mockResolvedValue({
241+
requestId: 'rejected-job',
242+
source: 'fallback_floor',
243+
costDollars: 0.05,
244+
})
245+
const receipts: string[] = []
246+
const response = await observeServiceCosts(
247+
async () => {},
248+
() =>
249+
executeImageGeneration(
250+
{ ...falInput, useHostedCostTracking: true },
251+
{ userId: 'user', requestId: 'rejected' }
252+
),
253+
async (receipt) => {
254+
receipts.push(receipt)
255+
}
256+
)
257+
expect(response.status).toBe(status === 422 ? 422 : 502)
258+
const body = await response.json()
259+
expect(body.error).toContain('no_media_generated')
260+
expect(JSON.stringify(body)).not.toContain('secret-prompt')
261+
expect(JSON.stringify(getMockLogger('ImageOperations').error.mock.calls)).not.toContain(
262+
'secret-prompt'
263+
)
264+
expect(receipts).toEqual([])
265+
expect(mocks.getFalAIEstimatedCostMetadata).not.toHaveBeenCalled()
266+
expect(mockUploadCopilotFile).not.toHaveBeenCalled()
267+
}
268+
)
269+
270+
it.each([
271+
{ bytes: 32 * 1024 * 1024, declared: true, expected: 200 },
272+
{ bytes: 101 * 1024 * 1024, declared: true, expected: 413 },
273+
{ bytes: 101 * 1024 * 1024, declared: false, expected: 413 },
274+
])(
275+
'bounds remote generated images ($bytes bytes, declared=$declared)',
276+
async ({ bytes, declared, expected }) => {
277+
mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'large-image' }))
278+
let remaining = bytes
279+
const chunk = new Uint8Array(1024 * 1024)
280+
const binary = new Response(
281+
new ReadableStream({
282+
pull(controller) {
283+
if (remaining <= 0) {
284+
controller.close()
285+
return
286+
}
287+
controller.enqueue(chunk)
288+
remaining -= chunk.length
289+
},
290+
}),
291+
{
292+
headers: {
293+
'Content-Type': 'image/png',
294+
...(declared ? { 'Content-Length': String(bytes) } : {}),
295+
},
296+
}
297+
)
298+
mockSecureFetchWithPinnedIP
299+
.mockResolvedValueOnce(Response.json({ status: 'COMPLETED' }))
300+
.mockResolvedValueOnce(
301+
Response.json({ images: [{ url: 'https://cdn.example/image.png' }] })
302+
)
303+
.mockResolvedValueOnce(binary)
304+
const response = await executeImageGeneration(falInput, {
305+
userId: 'user',
306+
requestId: 'large',
307+
})
308+
expect(response.status).toBe(expected)
309+
if (expected === 200) {
310+
expect(mockUploadCopilotFile.mock.calls[0][0].buffer.byteLength).toBe(bytes)
311+
} else {
312+
expect(mockUploadCopilotFile).not.toHaveBeenCalled()
313+
}
314+
}
315+
)
316+
it('reconciles exact image receipts when a completed queue status contains an error', async () => {
317+
mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'image-queue-error' }))
318+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
319+
Response.json({ status: 'COMPLETED', error: 'sensitive provider error' })
320+
)
321+
mocks.getFalAIBillingReceipt.mockResolvedValueOnce({
322+
requestId: 'image-queue-error',
323+
endpointId: 'fal-ai/nano-banana-2',
324+
source: 'billing_events',
325+
costDollars: 0.1,
326+
})
327+
const receipts: string[] = []
328+
const response = await observeServiceCosts(
329+
async () => {},
330+
() =>
331+
executeImageGeneration(
332+
{ ...falInput, useHostedCostTracking: true },
333+
{ userId: 'user', requestId: 'queue-error' }
334+
),
335+
async (receipt) => {
336+
receipts.push(receipt)
337+
}
338+
)
339+
expect(response.ok).toBe(false)
340+
expect(JSON.stringify(await response.json())).not.toContain('sensitive provider error')
341+
expect(receipts).toHaveLength(1)
342+
expect(receipts[0]).toContain('0.1 (billing_events)')
343+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)
214344
})
215345
})

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

Lines changed: 42 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ 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 { FalAIResultError, fetchFalAIResult } from '@/lib/media/falai-result'
2223
import { recordServiceMeteringFailure } from '@/lib/mothership/billing/service-observer'
23-
import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing'
24+
import type { FalAICostMetadata } from '@/lib/tools/falai-pricing'
2425
import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot'
2526
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
27+
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
2628
import { resolveStoredFileMetadata } from '@/lib/uploads/utils/stored-file-metadata'
2729

2830
const logger = createLogger('ImageOperations')
@@ -78,7 +80,7 @@ export async function executeImageGeneration(
7880
context: ImageOperationContext
7981
): Promise<Response> {
8082
const { requestId } = context
81-
let completedFalCost: FalAICostMetadata | undefined
83+
let undeliveredFalCost: FalAICostMetadata | undefined
8284
logger.info(`[${requestId}] Image generation request started`)
8385

8486
try {
@@ -108,7 +110,7 @@ export async function executeImageGeneration(
108110
requestId,
109111
logger,
110112
(cost) => {
111-
completedFalCost = cost
113+
undeliveredFalCost = cost
112114
},
113115
context.signal
114116
)
@@ -126,16 +128,16 @@ export async function executeImageGeneration(
126128

127129
return Response.json(storedImage)
128130
} catch (error) {
129-
if (completedFalCost) {
130-
logger.warn('Hosted fal image completed but delivery failed', {
131+
if (undeliveredFalCost) {
132+
logger.warn('Hosted fal image result delivery interrupted', {
131133
requestId,
132-
falaiBilling: completedFalCost,
134+
falaiBilling: undeliveredFalCost,
133135
})
134-
const { requestId: jobId, endpointId, costDollars } = completedFalCost
136+
const { requestId: jobId, endpointId, costDollars } = undeliveredFalCost
135137
await recordServiceMeteringFailure(
136-
`Completed fal image ${jobId} (${endpointId}): USD ${costDollars}; delivery failed`
138+
`Undelivered fal image ${jobId} (${endpointId}): USD ${costDollars} (${undeliveredFalCost.source}); reconciliation required`
137139
).catch((meteringError) => {
138-
logger.error('Failed to record completed fal image for reconciliation', {
140+
logger.error('Failed to record undelivered fal image for reconciliation', {
139141
requestId,
140142
error: getErrorMessage(meteringError),
141143
})
@@ -146,7 +148,14 @@ export async function executeImageGeneration(
146148
const errorMessage = getUserFacingErrorMessage(error, 'Unknown error')
147149
return Response.json(
148150
{ error: errorMessage },
149-
{ status: isPayloadSizeLimitError(error) ? 413 : 500 }
151+
{
152+
status:
153+
error instanceof FalAIResultError
154+
? error.status
155+
: isPayloadSizeLimitError(error)
156+
? 413
157+
: 500,
158+
}
150159
)
151160
}
152161
}
@@ -207,7 +216,7 @@ async function bufferFromImageUrl(
207216
const imageResponse = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, {
208217
profile: 'contentFetch',
209218
method: 'GET',
210-
maxResponseBytes: MAX_IMAGE_BYTES,
219+
maxResponseBytes: MAX_BUFFERED_TRANSFER_BYTES,
211220
signal,
212221
})
213222
if (!imageResponse.ok) {
@@ -221,7 +230,7 @@ async function bufferFromImageUrl(
221230

222231
const contentType = imageResponse.headers.get('content-type') || 'image/png'
223232
const buffer = await readResponseToBufferWithLimit(imageResponse, {
224-
maxBytes: MAX_IMAGE_BYTES,
233+
maxBytes: MAX_BUFFERED_TRANSFER_BYTES,
225234
label: 'generated image download',
226235
signal,
227236
})
@@ -500,46 +509,31 @@ async function generateWithFalAI(
500509

501510
const status = getStringProperty(statusData, 'status')
502511
if (status === 'COMPLETED') {
503-
const statusError = statusData.error
504-
if (statusError) {
505-
throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`)
506-
}
507-
508-
const falaiCost = body.useHostedCostTracking
509-
? await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId: falRequestId })
510-
: undefined
511-
if (falaiCost) onFalCost(falaiCost)
512-
signal?.throwIfAborted()
513-
514-
const resultUrl = falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl)
515-
const resultValidation = await validateUrlWithDNS(resultUrl, 'resultUrl', 'contentFetch')
516-
if (!resultValidation.isValid) {
517-
throw new Error(resultValidation.error)
518-
}
519-
const resultResponse = await secureFetchWithPinnedIP(resultUrl, resultValidation.resolvedIP, {
520-
profile: 'contentFetch',
521-
maxRedirects: 0,
522-
headers: {
523-
Authorization: `Key ${apiKey}`,
524-
},
512+
const { data: resultData, cost: falaiCost } = await fetchFalAIResult({
513+
apiKey,
514+
endpointId: endpoint,
515+
requestId: falRequestId,
516+
queueError: statusData.error,
525517
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
518+
trackCost: body.useHostedCostTracking,
519+
onCost: onFalCost,
526520
signal,
521+
fetchResponse: async (resultSignal) => {
522+
const resultUrl = falQueueUrl(
523+
getStringProperty(statusData, 'response_url') || responseUrl
524+
)
525+
const resultValidation = await validateUrlWithDNS(resultUrl, 'resultUrl', 'contentFetch')
526+
if (!resultValidation.isValid) throw new Error(resultValidation.error)
527+
return secureFetchWithPinnedIP(resultUrl, resultValidation.resolvedIP, {
528+
profile: 'contentFetch',
529+
maxRedirects: 0,
530+
headers: { Authorization: `Key ${apiKey}` },
531+
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
532+
signal: resultSignal,
533+
})
534+
},
527535
})
528536

529-
if (!resultResponse.ok) {
530-
await readResponseTextWithLimit(resultResponse, {
531-
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
532-
label: 'Fal.ai result error response',
533-
signal,
534-
}).catch(() => '')
535-
throw new Error(`Failed to fetch Fal.ai result: ${resultResponse.status}`)
536-
}
537-
538-
const resultData = await readResponseJsonWithLimit(resultResponse, {
539-
maxBytes: MAX_IMAGE_JSON_BYTES,
540-
label: 'Fal.ai result response',
541-
signal,
542-
})
543537
if (!isRecordLike(resultData)) {
544538
throw new Error('Invalid Fal.ai result response')
545539
}

0 commit comments

Comments
 (0)