Skip to content

Commit 25e88b7

Browse files
committed
fix(video): retain hosted fal receipts before delivery
1 parent 30c0967 commit 25e88b7

3 files changed

Lines changed: 106 additions & 24 deletions

File tree

‎apps/sim/lib/internal/video/client.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88
vi.mock('@/lib/core/execution-limits', () => executionLimitsMock)
99

1010
import { generateVideo } from '@/lib/internal/video/client'
11+
import { executeVideoOperation } from '@/lib/internal/video/operations'
12+
import { observeServiceCosts } from '@/lib/mothership/billing/service-observer'
1113

1214
executionLimitsMockFns.mockGetMaxExecutionTimeout.mockReturnValue(5000)
1315

@@ -117,6 +119,60 @@ describe('Video provider client', () => {
117119
)
118120
})
119121

122+
it('preserves the completed hosted receipt for reconciliation when Stop interrupts delivery', async () => {
123+
const controller = new AbortController()
124+
const requestId = 'completed-video'
125+
const endpoint = 'fal-ai/veo3.1'
126+
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
127+
init?.signal?.throwIfAborted()
128+
const path = new URL(url).pathname
129+
if (path === `/${endpoint}`) return jsonResponse({ request_id: requestId })
130+
if (path.endsWith('/status')) {
131+
controller.abort(new Error('Stopped'))
132+
return jsonResponse({ status: 'COMPLETED' })
133+
}
134+
if (path.endsWith('/billing-events')) {
135+
return jsonResponse({
136+
billing_events: [
137+
{ request_id: requestId, endpoint_id: endpoint, cost_estimate_nano_usd: 400000000 },
138+
],
139+
})
140+
}
141+
throw new Error('Unexpected provider request')
142+
})
143+
const pending: string[] = []
144+
const result = observeServiceCosts(
145+
async () => {
146+
throw new Error('Unexpected successful charge')
147+
},
148+
() =>
149+
executeVideoOperation(
150+
{
151+
provider: 'falai',
152+
model: 'veo-3.1',
153+
prompt: 'A sunrise',
154+
apiKey: 'key',
155+
useHostedCostTracking: true,
156+
},
157+
{
158+
requestId: 'trace-id',
159+
userId: 'user',
160+
headers: new Headers(),
161+
signal: controller.signal,
162+
}
163+
),
164+
async (receipt) => {
165+
pending.push(receipt)
166+
}
167+
)
168+
const rejection = expect(result).rejects.toThrow('Stopped')
169+
await vi.advanceTimersByTimeAsync(5000)
170+
await rejection
171+
expect(pending).toHaveLength(1)
172+
expect(pending[0]).toContain(requestId)
173+
expect(pending[0]).toContain('0.4')
174+
})
175+
120176
it('cancels during the provider wait without polling or resubmitting', async () => {
121177
const controller = new AbortController()
122178
const mockFetch = vi.fn().mockResolvedValueOnce(jsonResponse({ id: 'task-1' }))

‎apps/sim/lib/internal/video/client.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface VideoGenerationResult {
5151
}
5252

5353
export interface VideoGenerationContext {
54+
onFalCost?: (receipt: FalAICostMetadata) => void
5455
requestId: string
5556
signal?: AbortSignal
5657
}
@@ -820,6 +821,12 @@ async function generateWithFalAI(
820821

821822
logger.info(`[${requestId}] Fal.ai generation completed after ${attempts * 5}s`)
822823

824+
const falaiCost = useHostedCostTracking
825+
? await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId: requestIdFal })
826+
: undefined
827+
if (falaiCost) context.onFalCost?.(falaiCost)
828+
signal?.throwIfAborted()
829+
823830
const resultResponse = await fetch(
824831
falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl),
825832
{
@@ -870,14 +877,7 @@ async function generateWithFalAI(
870877
getNumberProperty(resultData, 'duration') ??
871878
duration ??
872879
(legacyMetadata ? 5 : undefined),
873-
falaiCost: useHostedCostTracking
874-
? await getFalAICostMetadata({
875-
apiKey,
876-
endpointId: endpoint,
877-
requestId: requestIdFal,
878-
signal,
879-
})
880-
: undefined,
880+
falaiCost,
881881
}
882882
}
883883

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

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
23
import { getUserFacingErrorMessage } from '@/lib/core/errors/database-query-error'
34
import { getBaseUrl } from '@/lib/core/utils/urls'
45
import { validateOpaqueModelInputProvenance } from '@/lib/execution/model-input-provenance'
@@ -8,6 +9,8 @@ import {
89
type VideoGenerationInput,
910
} from '@/lib/internal/video/client'
1011
import { VideoOperationError } from '@/lib/internal/video/errors'
12+
import { recordServiceMeteringFailure } from '@/lib/mothership/billing/service-observer'
13+
import type { FalAICostMetadata } from '@/lib/tools/falai-pricing'
1114
import { StorageService } from '@/lib/uploads'
1215
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
1316
import {
@@ -142,22 +145,45 @@ export async function executeVideoOperation(
142145
if (validationError) throw new VideoOperationError(validationError, 400)
143146
await authorizeVisualReference(input, context)
144147

145-
const generated = await generateVideo(input, {
146-
requestId: context.requestId,
147-
signal: context.signal,
148-
})
149-
context.signal?.throwIfAborted()
150-
const stored = await storeVideo(input, generated.buffer, context)
148+
let completedFalCost: FalAICostMetadata | undefined
149+
try {
150+
const generated = await generateVideo(input, {
151+
requestId: context.requestId,
152+
signal: context.signal,
153+
onFalCost: (cost) => {
154+
completedFalCost = cost
155+
},
156+
})
157+
context.signal?.throwIfAborted()
158+
const stored = await storeVideo(input, generated.buffer, context)
151159

152-
return {
153-
...stored,
154-
duration: generated.duration || input.duration,
155-
width: generated.width,
156-
height: generated.height,
157-
provider: input.provider,
158-
model: input.model || 'default',
159-
jobId: generated.jobId,
160-
__falaiCostDollars: generated.falaiCost?.costDollars,
161-
__falaiBilling: generated.falaiCost,
160+
return {
161+
...stored,
162+
duration: generated.duration || input.duration,
163+
width: generated.width,
164+
height: generated.height,
165+
provider: input.provider,
166+
model: input.model || 'default',
167+
jobId: generated.jobId,
168+
__falaiCostDollars: generated.falaiCost?.costDollars,
169+
__falaiBilling: generated.falaiCost,
170+
}
171+
} catch (error) {
172+
if (completedFalCost) {
173+
logger.warn('Hosted fal video completed but delivery failed', {
174+
requestId: context.requestId,
175+
falaiBilling: completedFalCost,
176+
})
177+
const { requestId, endpointId, costDollars } = completedFalCost
178+
await recordServiceMeteringFailure(
179+
`Completed fal video ${requestId} (${endpointId}): USD ${costDollars}; delivery failed`
180+
).catch((meteringError) => {
181+
logger.error('Failed to record completed fal video for reconciliation', {
182+
requestId: context.requestId,
183+
error: getErrorMessage(meteringError),
184+
})
185+
})
186+
}
187+
throw error
162188
}
163189
}

0 commit comments

Comments
 (0)