diff --git a/apps/sim/lib/internal/image/operations.test.ts b/apps/sim/lib/internal/image/operations.test.ts index e9b59945001..7021860ffb1 100644 --- a/apps/sim/lib/internal/image/operations.test.ts +++ b/apps/sim/lib/internal/image/operations.test.ts @@ -6,6 +6,7 @@ import { inputValidationMock, inputValidationMockFns, } from '@sim/testing/mocks/input-validation.mock' +import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { uploadsCopilotMock, uploadsCopilotMockFns } from '@sim/testing/mocks/uploads-copilot.mock' import { uploadsExecutionMock } from '@sim/testing/mocks/uploads-execution.mock' import { utilsHelpersMock, utilsHelpersMockFns } from '@sim/testing/mocks/utils-helpers.mock' @@ -13,7 +14,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ fetch: vi.fn(), - getFalAICostMetadata: vi.fn(), + getFalAIEstimatedCostMetadata: vi.fn(), + getFalAIBillingReceipt: vi.fn(), })) vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) @@ -23,7 +25,9 @@ vi.mock('@sim/utils/helpers', () => utilsHelpersMock) vi.mock('@/lib/core/execution-limits', () => executionLimitsMock) vi.mock('@/lib/tools/falai-pricing', () => ({ - getFalAICostMetadata: mocks.getFalAICostMetadata, + FALAI_COST_LOOKUP_TIMEOUT_MS: 10_000, + getFalAIEstimatedCostMetadata: mocks.getFalAIEstimatedCostMetadata, + getFalAIBillingReceipt: mocks.getFalAIBillingReceipt, })) vi.mock('@/lib/uploads/contexts/copilot', () => uploadsCopilotMock) @@ -33,6 +37,7 @@ vi.mock('@/lib/uploads/contexts/execution', () => uploadsExecutionMock) const { mockSecureFetchWithPinnedIP, mockValidateUrlWithDNS } = inputValidationMockFns import { executeImageGeneration } from '@/lib/internal/image/operations' +import { observeServiceCosts } from '@/lib/mothership/billing/service-observer' const { mockInterruptibleSleep } = utilsHelpersMockFns @@ -50,6 +55,8 @@ const falInput = { describe('image operations', () => { beforeEach(() => { mocks.fetch.mockReset() + mocks.getFalAIEstimatedCostMetadata.mockReset() + mocks.getFalAIBillingReceipt.mockReset().mockResolvedValue(undefined) mockSecureFetchWithPinnedIP.mockReset() vi.stubGlobal('fetch', mocks.fetch) mockInterruptibleSleep.mockResolvedValue(undefined) @@ -182,15 +189,17 @@ describe('image operations', () => { it('retains the completed fal receipt when delivery is interrupted', async () => { const controller = new AbortController() mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'paid-job' })) - mocks.getFalAICostMetadata.mockImplementation(async ({ signal }: { signal?: AbortSignal }) => { - signal?.throwIfAborted() - return { - requestId: 'paid-job', - endpointId: 'fal-ai/nano-banana-2', - costDollars: 0.1, - source: 'billing_events', + mocks.getFalAIBillingReceipt.mockImplementation( + async ({ signal }: { signal?: AbortSignal }) => { + signal?.throwIfAborted() + return { + requestId: 'paid-job', + endpointId: 'fal-ai/nano-banana-2', + costDollars: 0.1, + source: 'billing_events', + } } - }) + ) mockSecureFetchWithPinnedIP.mockResolvedValueOnce(Response.json({ status: 'COMPLETED' })) mockSecureFetchWithPinnedIP.mockImplementationOnce(async () => { controller.abort() @@ -207,9 +216,130 @@ describe('image operations', () => { } ) ).rejects.toMatchObject({ name: 'AbortError' }) - expect(mocks.getFalAICostMetadata).toHaveBeenCalledWith( + expect(mocks.getFalAIBillingReceipt).toHaveBeenCalledWith( expect.objectContaining({ requestId: 'paid-job' }) ) - expect(mocks.getFalAICostMetadata.mock.calls[0][0].signal).toBeUndefined() + expect(mocks.getFalAIBillingReceipt.mock.calls[0][0].signal).toBeUndefined() + }) + it.each([422, 500])( + 'reports HTTP %i generation failures without speculative delivery receipts', + async (status) => { + mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'rejected-job' })) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(Response.json({ status: 'COMPLETED' })) + .mockResolvedValueOnce( + Response.json( + { + detail: [ + { type: 'no_media_generated', msg: 'secret-prompt', input: 'secret-prompt' }, + ], + }, + { status } + ) + ) + mocks.getFalAIEstimatedCostMetadata.mockResolvedValue({ + requestId: 'rejected-job', + source: 'fallback_floor', + costDollars: 0.05, + }) + const receipts: string[] = [] + const response = await observeServiceCosts( + async () => {}, + () => + executeImageGeneration( + { ...falInput, useHostedCostTracking: true }, + { userId: 'user', requestId: 'rejected' } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + expect(response.status).toBe(status === 422 ? 422 : 502) + const body = await response.json() + expect(body.error).toContain('no_media_generated') + expect(JSON.stringify(body)).not.toContain('secret-prompt') + expect(JSON.stringify(getMockLogger('ImageOperations').error.mock.calls)).not.toContain( + 'secret-prompt' + ) + expect(receipts).toEqual([]) + expect(mocks.getFalAIEstimatedCostMetadata).not.toHaveBeenCalled() + expect(mockUploadCopilotFile).not.toHaveBeenCalled() + } + ) + + it.each([ + { bytes: 32 * 1024 * 1024, declared: true, expected: 200 }, + { bytes: 101 * 1024 * 1024, declared: true, expected: 413 }, + { bytes: 101 * 1024 * 1024, declared: false, expected: 413 }, + ])( + 'bounds remote generated images ($bytes bytes, declared=$declared)', + async ({ bytes, declared, expected }) => { + mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'large-image' })) + let remaining = bytes + const chunk = new Uint8Array(1024 * 1024) + const binary = new Response( + new ReadableStream({ + pull(controller) { + if (remaining <= 0) { + controller.close() + return + } + controller.enqueue(chunk) + remaining -= chunk.length + }, + }), + { + headers: { + 'Content-Type': 'image/png', + ...(declared ? { 'Content-Length': String(bytes) } : {}), + }, + } + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(Response.json({ status: 'COMPLETED' })) + .mockResolvedValueOnce( + Response.json({ images: [{ url: 'https://cdn.example/image.png' }] }) + ) + .mockResolvedValueOnce(binary) + const response = await executeImageGeneration(falInput, { + userId: 'user', + requestId: 'large', + }) + expect(response.status).toBe(expected) + if (expected === 200) { + expect(mockUploadCopilotFile.mock.calls[0][0].buffer.byteLength).toBe(bytes) + } else { + expect(mockUploadCopilotFile).not.toHaveBeenCalled() + } + } + ) + it('reconciles exact image receipts when a completed queue status contains an error', async () => { + mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'image-queue-error' })) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + Response.json({ status: 'COMPLETED', error: 'sensitive provider error' }) + ) + mocks.getFalAIBillingReceipt.mockResolvedValueOnce({ + requestId: 'image-queue-error', + endpointId: 'fal-ai/nano-banana-2', + source: 'billing_events', + costDollars: 0.1, + }) + const receipts: string[] = [] + const response = await observeServiceCosts( + async () => {}, + () => + executeImageGeneration( + { ...falInput, useHostedCostTracking: true }, + { userId: 'user', requestId: 'queue-error' } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + expect(response.ok).toBe(false) + expect(JSON.stringify(await response.json())).not.toContain('sensitive provider error') + expect(receipts).toHaveLength(1) + expect(receipts[0]).toContain('0.1 (billing_events)') + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/sim/lib/internal/image/operations.ts b/apps/sim/lib/internal/image/operations.ts index 0feef97bf1f..4f3ba6884ce 100644 --- a/apps/sim/lib/internal/image/operations.ts +++ b/apps/sim/lib/internal/image/operations.ts @@ -19,10 +19,12 @@ import { import { MAX_REMOTE_IMAGE_BYTES } from '@/lib/internal/image/fetch' import { buildImageGenerationRequest } from '@/lib/internal/image/request' import type { ImageGenerationInput, ImageProvider } from '@/lib/internal/image/schema' +import { FalAIResultError, fetchFalAIResult } from '@/lib/media/falai-result' import { recordServiceMeteringFailure } from '@/lib/mothership/billing/service-observer' -import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' +import type { FalAICostMetadata } from '@/lib/tools/falai-pricing' import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { resolveStoredFileMetadata } from '@/lib/uploads/utils/stored-file-metadata' const logger = createLogger('ImageOperations') @@ -78,7 +80,7 @@ export async function executeImageGeneration( context: ImageOperationContext ): Promise { const { requestId } = context - let completedFalCost: FalAICostMetadata | undefined + let undeliveredFalCost: FalAICostMetadata | undefined logger.info(`[${requestId}] Image generation request started`) try { @@ -108,7 +110,7 @@ export async function executeImageGeneration( requestId, logger, (cost) => { - completedFalCost = cost + undeliveredFalCost = cost }, context.signal ) @@ -126,16 +128,16 @@ export async function executeImageGeneration( return Response.json(storedImage) } catch (error) { - if (completedFalCost) { - logger.warn('Hosted fal image completed but delivery failed', { + if (undeliveredFalCost) { + logger.warn('Hosted fal image result delivery interrupted', { requestId, - falaiBilling: completedFalCost, + falaiBilling: undeliveredFalCost, }) - const { requestId: jobId, endpointId, costDollars } = completedFalCost + const { requestId: jobId, endpointId, costDollars } = undeliveredFalCost await recordServiceMeteringFailure( - `Completed fal image ${jobId} (${endpointId}): USD ${costDollars}; delivery failed` + `Undelivered fal image ${jobId} (${endpointId}): USD ${costDollars} (${undeliveredFalCost.source}); reconciliation required` ).catch((meteringError) => { - logger.error('Failed to record completed fal image for reconciliation', { + logger.error('Failed to record undelivered fal image for reconciliation', { requestId, error: getErrorMessage(meteringError), }) @@ -146,7 +148,14 @@ export async function executeImageGeneration( const errorMessage = getUserFacingErrorMessage(error, 'Unknown error') return Response.json( { error: errorMessage }, - { status: isPayloadSizeLimitError(error) ? 413 : 500 } + { + status: + error instanceof FalAIResultError + ? error.status + : isPayloadSizeLimitError(error) + ? 413 + : 500, + } ) } } @@ -207,7 +216,7 @@ async function bufferFromImageUrl( const imageResponse = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { profile: 'contentFetch', method: 'GET', - maxResponseBytes: MAX_IMAGE_BYTES, + maxResponseBytes: MAX_BUFFERED_TRANSFER_BYTES, signal, }) if (!imageResponse.ok) { @@ -221,7 +230,7 @@ async function bufferFromImageUrl( const contentType = imageResponse.headers.get('content-type') || 'image/png' const buffer = await readResponseToBufferWithLimit(imageResponse, { - maxBytes: MAX_IMAGE_BYTES, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, label: 'generated image download', signal, }) @@ -500,46 +509,31 @@ async function generateWithFalAI( const status = getStringProperty(statusData, 'status') if (status === 'COMPLETED') { - const statusError = statusData.error - if (statusError) { - throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`) - } - - const falaiCost = body.useHostedCostTracking - ? await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId: falRequestId }) - : undefined - if (falaiCost) onFalCost(falaiCost) - signal?.throwIfAborted() - - const resultUrl = falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl) - const resultValidation = await validateUrlWithDNS(resultUrl, 'resultUrl', 'contentFetch') - if (!resultValidation.isValid) { - throw new Error(resultValidation.error) - } - const resultResponse = await secureFetchWithPinnedIP(resultUrl, resultValidation.resolvedIP, { - profile: 'contentFetch', - maxRedirects: 0, - headers: { - Authorization: `Key ${apiKey}`, - }, + const { data: resultData, cost: falaiCost } = await fetchFalAIResult({ + apiKey, + endpointId: endpoint, + requestId: falRequestId, + queueError: statusData.error, maxResponseBytes: MAX_IMAGE_JSON_BYTES, + trackCost: body.useHostedCostTracking, + onCost: onFalCost, signal, + fetchResponse: async (resultSignal) => { + const resultUrl = falQueueUrl( + getStringProperty(statusData, 'response_url') || responseUrl + ) + const resultValidation = await validateUrlWithDNS(resultUrl, 'resultUrl', 'contentFetch') + if (!resultValidation.isValid) throw new Error(resultValidation.error) + return secureFetchWithPinnedIP(resultUrl, resultValidation.resolvedIP, { + profile: 'contentFetch', + maxRedirects: 0, + headers: { Authorization: `Key ${apiKey}` }, + maxResponseBytes: MAX_IMAGE_JSON_BYTES, + signal: resultSignal, + }) + }, }) - if (!resultResponse.ok) { - await readResponseTextWithLimit(resultResponse, { - maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, - label: 'Fal.ai result error response', - signal, - }).catch(() => '') - throw new Error(`Failed to fetch Fal.ai result: ${resultResponse.status}`) - } - - const resultData = await readResponseJsonWithLimit(resultResponse, { - maxBytes: MAX_IMAGE_JSON_BYTES, - label: 'Fal.ai result response', - signal, - }) if (!isRecordLike(resultData)) { throw new Error('Invalid Fal.ai result response') } diff --git a/apps/sim/lib/internal/video/client.test.ts b/apps/sim/lib/internal/video/client.test.ts index 055a7a547dd..5947d6f329e 100644 --- a/apps/sim/lib/internal/video/client.test.ts +++ b/apps/sim/lib/internal/video/client.test.ts @@ -517,6 +517,252 @@ describe('Video provider client', () => { ) }) + it.each([422, 500])( + 'does not reconcile a failed fal result (HTTP %i) as delivered media', + async (status) => { + const paths: string[] = [] + vi.stubGlobal('fetch', async (url: string | URL) => { + const path = new URL(url).pathname + paths.push(path) + if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' }) + if (path.includes('/requests/')) + return jsonResponse( + { + detail: [ + { type: 'no_media_generated', msg: 'secret-prompt', input: 'secret-prompt' }, + ], + }, + status + ) + if (path.endsWith('/billing-events')) return jsonResponse({ billing_events: [] }) + if (path.endsWith('/estimate')) return jsonResponse({ total_cost: 0.25 }) + return jsonResponse({ request_id: 'failed-result' }) + }) + const pending: string[] = [] + const result = observeServiceCosts( + async () => {}, + () => + executeVideoOperation( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: 'key', + useHostedCostTracking: true, + }, + { requestId: 'trace', userId: 'user', headers: new Headers() } + ), + async (receipt) => { + pending.push(receipt) + } + ) + const rejection = expect(result).rejects.toMatchObject({ + status: status === 422 ? 422 : 502, + message: expect.stringContaining('no_media_generated'), + }) + await Promise.all([rejection, vi.advanceTimersByTimeAsync(6000)]) + expect(pending).toEqual([]) + expect(paths.some((path) => path.endsWith('/estimate'))).toBe(false) + } + ) + + it.each([ + { status: 429, body: 'Rate limit exceeded' }, + { status: 503, body: 'Service unavailable' }, + ])( + 'retains reconciliation when result retrieval fails with HTTP $status', + async ({ status, body }) => { + let submissions = 0 + vi.stubGlobal('fetch', async (url: string | URL) => { + const path = new URL(url).pathname + if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' }) + if (path.includes('/requests/')) return new Response(body, { status }) + if (path.endsWith('/billing-events')) return jsonResponse({ billing_events: [] }) + if (path.endsWith('/estimate')) return jsonResponse({ total_cost: 0.25 }) + submissions++ + return jsonResponse({ request_id: `unavailable-${status}` }) + }) + const receipts: string[] = [] + const result = observeServiceCosts( + async () => {}, + () => + executeVideoOperation( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: `delivery-${status}`, + useHostedCostTracking: true, + }, + { requestId: 'trace', userId: 'user', headers: new Headers() } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + const rejection = expect(result).rejects.toMatchObject({ status: 502 }) + await Promise.all([rejection, vi.advanceTimersByTimeAsync(6000)]) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toContain(`unavailable-${status}`) + expect(receipts[0]).toContain('0.25 (historical_estimate)') + expect(submissions).toBe(1) + } + ) + + it.each(['headers', 'body'])( + 'captures the exact receipt before a stalled result %s settles', + async (stall) => { + let resultSignal: AbortSignal | null | undefined + const costs: number[] = [] + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + const path = new URL(url).pathname + if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' }) + if (path.endsWith('/billing-events')) + return jsonResponse({ + billing_events: [ + { + request_id: 'stalled-result', + endpoint_id: 'fal-ai/veo3.1', + cost_estimate_nano_usd: 400000000, + }, + ], + }) + if (path.includes('/requests/')) { + resultSignal = init?.signal + return stall === 'headers' + ? new Promise(() => {}) + : new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')) + }, + }) + ) + } + return jsonResponse({ request_id: 'stalled-result' }) + }) + const result = generateVideo( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: 'stall-key', + useHostedCostTracking: true, + }, + { + requestId: 'trace', + onFalCost: (cost) => { + costs.push(cost.costDollars) + }, + } + ) + let outcome: unknown = 'pending' + void result.then( + (value) => { + outcome = value + }, + (error) => { + outcome = error + } + ) + await vi.advanceTimersByTimeAsync(5000) + const earlyCosts = [...costs] + await vi.advanceTimersByTimeAsync(30001) + expect(earlyCosts).toEqual([0.4]) + expect(outcome).toMatchObject({ name: 'DeadlineExceededError' }) + expect(resultSignal?.aborted).toBe(true) + expect(costs).toEqual([0.4]) + } + ) + + it('reconciles an exact receipt for a terminal queue error without fetching or estimating a result', async () => { + const paths: string[] = [] + vi.stubGlobal('fetch', async (url: string | URL) => { + const path = new URL(url).pathname + paths.push(path) + if (path.endsWith('/status')) + return jsonResponse({ status: 'COMPLETED', error: 'sensitive provider error' }) + if (path.endsWith('/billing-events')) + return jsonResponse({ + billing_events: [ + { + request_id: 'queue-error', + endpoint_id: 'fal-ai/veo3.1', + cost_estimate_nano_usd: 400000000, + }, + ], + }) + return jsonResponse({ request_id: 'queue-error' }) + }) + const receipts: string[] = [] + const result = observeServiceCosts( + async () => {}, + () => + executeVideoOperation( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: 'queue-key', + useHostedCostTracking: true, + }, + { requestId: 'trace', userId: 'user', headers: new Headers() } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + const rejected = expect(result).rejects.toThrow() + await Promise.all([rejected, vi.advanceTimersByTimeAsync(5000)]) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toContain('0.4 (billing_events)') + expect( + paths.some((path) => path.endsWith('/estimate') || path.endsWith('/requests/queue-error')) + ).toBe(false) + }) + + it('preserves an exact charge even when the provider returns a failed result', async () => { + vi.stubGlobal('fetch', async (url: string | URL) => { + const path = new URL(url).pathname + if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' }) + if (path.includes('/requests/')) + return jsonResponse({ detail: [{ type: 'no_media_generated' }] }, 422) + if (path.endsWith('/billing-events')) + return jsonResponse({ + billing_events: [ + { + request_id: 'charged-failure', + endpoint_id: 'fal-ai/veo3.1', + cost_estimate_nano_usd: 400000000, + }, + ], + }) + return jsonResponse({ request_id: 'charged-failure' }) + }) + const receipts: string[] = [] + const result = observeServiceCosts( + async () => {}, + () => + executeVideoOperation( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: 'key', + useHostedCostTracking: true, + }, + { requestId: 'trace', userId: 'user', headers: new Headers() } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + const rejection = expect(result).rejects.toMatchObject({ status: 422 }) + await Promise.all([rejection, vi.advanceTimersByTimeAsync(6000)]) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toContain('0.4 (billing_events)') + }) + it('preserves the completed hosted receipt for reconciliation when Stop interrupts delivery', async () => { const controller = new AbortController() const requestId = 'completed-video' diff --git a/apps/sim/lib/internal/video/client.ts b/apps/sim/lib/internal/video/client.ts index 7ce53e77161..28e4de084de 100644 --- a/apps/sim/lib/internal/video/client.ts +++ b/apps/sim/lib/internal/video/client.ts @@ -18,7 +18,8 @@ import { buildDirectVideoRequest } from '@/lib/internal/video/direct-request' import { buildFalVideoRequest, getFalAIValidationError } from '@/lib/internal/video/fal-request' import { getFalVideoModel } from '@/lib/media/fal-video-catalog' import { downloadFalMedia } from '@/lib/media/falai' -import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' +import { fetchFalAIResult } from '@/lib/media/falai-result' +import type { FalAICostMetadata } from '@/lib/tools/falai-pricing' import { downloadFileFromStorage } from '@/lib/uploads/utils/file.server' import type { UserFile } from '@/executor/types' @@ -423,36 +424,23 @@ async function generateWithFalAI( } if (getStringProperty(statusData, 'status') === 'COMPLETED') { - const statusError = statusData.error - if (statusError) { - throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`) - } - - logger.info(`[${requestId}] Fal.ai generation completed after ${attempts * 5}s`) - - const falaiCost = useHostedCostTracking - ? await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId: requestIdFal }) - : undefined - if (falaiCost) context.onFalCost?.(falaiCost) - signal?.throwIfAborted() - - const resultResponse = await fetch( - falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl), - { - redirect: 'error', - headers: { - Authorization: `Key ${apiKey}`, - }, - signal, - } - ) - - if (!resultResponse.ok) { - await readVideoErrorText(resultResponse, 'Fal.ai result error response') - throw new Error(`Failed to fetch result: ${resultResponse.status}`) - } + const { data: resultData, cost: falaiCost } = await fetchFalAIResult({ + apiKey, + endpointId: endpoint, + requestId: requestIdFal, + queueError: statusData.error, + maxResponseBytes: MAX_VIDEO_JSON_BYTES, + trackCost: useHostedCostTracking, + onCost: context.onFalCost, + signal, + fetchResponse: (resultSignal) => + fetch(falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl), { + redirect: 'error', + headers: { Authorization: `Key ${apiKey}` }, + signal: resultSignal, + }), + }) - const resultData = await readVideoJson(resultResponse, 'Fal.ai result response') if (!isRecordLike(resultData)) { throw new Error('Invalid Fal.ai result response') } diff --git a/apps/sim/lib/internal/video/execute-tool.ts b/apps/sim/lib/internal/video/execute-tool.ts index e55522e0349..0d35019d3e0 100644 --- a/apps/sim/lib/internal/video/execute-tool.ts +++ b/apps/sim/lib/internal/video/execute-tool.ts @@ -7,6 +7,7 @@ import type { InternalToolOperationHandler } from '@/lib/internal/tool-operation import type { VideoProvider } from '@/lib/internal/video/client' import { VideoOperationError } from '@/lib/internal/video/errors' import { executeVideoOperation } from '@/lib/internal/video/operations' +import { FalAIResultError } from '@/lib/media/falai-result' const VIDEO_TOOL_IDS = new Set([ 'video_falai', @@ -79,7 +80,14 @@ export const executeVideoTool: InternalToolOperationHandler = async (request) => } return Response.json( { error: getErrorMessage(error, 'Video generation failed') }, - { status: isPayloadSizeLimitError(error) ? 413 : 500 } + { + status: + error instanceof FalAIResultError + ? error.status + : isPayloadSizeLimitError(error) + ? 413 + : 500, + } ) } } diff --git a/apps/sim/lib/internal/video/operations.ts b/apps/sim/lib/internal/video/operations.ts index 3598310cdcd..a18a4858aa1 100644 --- a/apps/sim/lib/internal/video/operations.ts +++ b/apps/sim/lib/internal/video/operations.ts @@ -145,13 +145,13 @@ export async function executeVideoOperation( if (validationError) throw new VideoOperationError(validationError, 400) await authorizeVisualReference(input, context) - let completedFalCost: FalAICostMetadata | undefined + let undeliveredFalCost: FalAICostMetadata | undefined try { const generated = await generateVideo(input, { requestId: context.requestId, signal: context.signal, onFalCost: (cost) => { - completedFalCost = cost + undeliveredFalCost = cost }, }) context.signal?.throwIfAborted() @@ -169,16 +169,16 @@ export async function executeVideoOperation( __falaiBilling: generated.falaiCost, } } catch (error) { - if (completedFalCost) { - logger.warn('Hosted fal video completed but delivery failed', { + if (undeliveredFalCost) { + logger.warn('Hosted fal video result delivery interrupted', { requestId: context.requestId, - falaiBilling: completedFalCost, + falaiBilling: undeliveredFalCost, }) - const { requestId, endpointId, costDollars } = completedFalCost + const { requestId, endpointId, costDollars } = undeliveredFalCost await recordServiceMeteringFailure( - `Completed fal video ${requestId} (${endpointId}): USD ${costDollars}; delivery failed` + `Undelivered fal video ${requestId} (${endpointId}): USD ${costDollars} (${undeliveredFalCost.source}); reconciliation required` ).catch((meteringError) => { - logger.error('Failed to record completed fal video for reconciliation', { + logger.error('Failed to record undelivered fal video for reconciliation', { requestId: context.requestId, error: getErrorMessage(meteringError), }) diff --git a/apps/sim/lib/media/falai-result.ts b/apps/sim/lib/media/falai-result.ts new file mode 100644 index 00000000000..9fc45a9a72f --- /dev/null +++ b/apps/sim/lib/media/falai-result.ts @@ -0,0 +1,146 @@ +import { isRecordLike } from '@sim/utils/object' +import { withinDeadline } from '@/lib/core/utils/deadline' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, +} from '@/lib/core/utils/stream-limits' +import { + FALAI_COST_LOOKUP_TIMEOUT_MS, + type FalAICostMetadata, + getFalAIBillingReceipt, + getFalAIEstimatedCostMetadata, +} from '@/lib/tools/falai-pricing' + +const FALAI_RESULT_TIMEOUT_MS = 30_000 +const SAFE_ERROR_TYPES = new Set([ + 'content_policy_violation', + 'no_media_generated', + 'internal_server_error', + 'generation_timeout', + 'downstream_service_error', + 'downstream_service_unavailable', + 'value_error', + 'missing', + 'string_type', + 'int_parsing', + 'enum', + 'image_too_large', + 'image_too_small', +]) + +interface FalAIResultResponse { + ok: boolean + status: number + body: ReadableStream | null + headers: { get(name: string): string | null } +} + +/** A fal result error, without provider messages or inputs that may echo private content. */ +export class FalAIResultError extends Error { + readonly status: number + + constructor(providerStatus: number | undefined, errorTypes: string[]) { + super( + `Fal.ai result unavailable${providerStatus ? ` (HTTP ${providerStatus})` : ''}${errorTypes.length ? `: ${errorTypes.join(', ')}` : ''}` + ) + this.name = 'FalAIResultError' + this.status = providerStatus === 422 ? 422 : 502 + } +} + +function safeErrorTypes(value: unknown): string[] { + const details = isRecordLike(value) && Array.isArray(value.detail) ? value.detail : [value] + return [ + ...new Set( + details.flatMap((detail) => + isRecordLike(detail) && typeof detail.type === 'string' && SAFE_ERROR_TYPES.has(detail.type) + ? [detail.type] + : [] + ) + ), + ] +} + +/** + * Starts exact receipt tracking at queue completion while bounding result retrieval. Only explicit + * model failures skip estimates; transport failures still need a reconciliation record. + */ +export async function fetchFalAIResult({ + fetchResponse, + apiKey, + endpointId, + requestId, + trackCost, + onCost, + signal, + maxResponseBytes, + queueError, +}: { + fetchResponse: (signal: AbortSignal) => Promise + apiKey: string + endpointId: string + requestId: string + trackCost?: boolean + onCost?: (cost: FalAICostMetadata) => void + signal?: AbortSignal + maxResponseBytes: number + queueError?: unknown +}): Promise<{ data: unknown; cost?: FalAICostMetadata }> { + const lookup = { + apiKey, + endpointId, + requestId, + deadlineAt: Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS, + } + let cost: FalAICostMetadata | undefined + let data: unknown + let generationFailed = Boolean(queueError) + const receipt = trackCost + ? getFalAIBillingReceipt(lookup).then((value) => { + if (value) { + cost = value + onCost?.(value) + } + return value + }) + : Promise.resolve(undefined) + try { + const [resultData] = await Promise.all([ + withinDeadline( + async (resultSignal) => { + if (generationFailed) throw new FalAIResultError(undefined, safeErrorTypes(queueError)) + const response = await fetchResponse(resultSignal) + if (!response.ok) { + generationFailed = response.status === 422 + const errorData = await readResponseJsonWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai result error response', + signal: resultSignal, + }).catch(() => undefined) + const types = safeErrorTypes(errorData) + generationFailed ||= response.status >= 500 && types.length > 0 + throw new FalAIResultError(response.status, types) + } + return readResponseJsonWithLimit(response, { + maxBytes: maxResponseBytes, + label: 'Fal.ai result response', + signal: resultSignal, + }) + }, + Date.now() + FALAI_RESULT_TIMEOUT_MS, + signal + ), + receipt, + ]) + data = resultData + } finally { + // Stop cannot erase a receipt; both pricing phases share the original ten-second deadline. + await receipt + if (trackCost && !cost && !generationFailed) { + cost = await getFalAIEstimatedCostMetadata(lookup) + onCost?.(cost) + } + } + signal?.throwIfAborted() + return { data, cost } +} diff --git a/apps/sim/lib/tools/falai-pricing.test.ts b/apps/sim/lib/tools/falai-pricing.test.ts index 65d8c3187df..3b657ebc019 100644 --- a/apps/sim/lib/tools/falai-pricing.test.ts +++ b/apps/sim/lib/tools/falai-pricing.test.ts @@ -1,3 +1,5 @@ +import { createDeferred } from '@sim/testing/helpers/deferred' +import { jsonResponse } from '@sim/testing/helpers/http' import { utilsHelpersMock } from '@sim/testing/mocks/utils-helpers.mock' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -5,27 +7,17 @@ import { FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS, FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS, getFalAICostMetadata, -} from './falai-pricing' +} from '@/lib/tools/falai-pricing' // Avoid the real inter-attempt backoff so the fallback path resolves instantly. vi.mock('@sim/utils/helpers', () => utilsHelpersMock) describe('getFalAICostMetadata fallback floor', () => { - const originalFetch = global.fetch - beforeEach(() => { - // Both fal cost endpoints (billing-events + pricing estimate) fail, forcing - // the provider-cost floor selection by endpoint category. - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({}), - text: async () => 'error', - }) as unknown as typeof fetch - }) - - afterEach(() => { - global.fetch = originalFetch + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('error', { status: 500 })) + ) }) it.each([ @@ -66,10 +58,241 @@ describe('getFalAICostMetadata fallback floor', () => { expect(result.source).toBe('fallback_floor') expect(result.costDollars).toBe(FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS) }) +}) + +describe('Fal pricing bursts', () => { + let lookup = getFalAICostMetadata + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + vi.spyOn(performance, 'now').mockImplementation(() => Date.now()) + vi.resetModules() + lookup = (await import('@/lib/tools/falai-pricing')).getFalAICostMetadata + }) + + afterEach(() => vi.useRealTimers()) + + it('coalesces historical estimates while keeping receipts per job and credential', async () => { + const estimates: string[] = [] + const receipts: string[] = [] + let accountCost = 0 + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + if (String(url).includes('billing-events')) { + const id = new URL(url).searchParams.get('request_id') || '' + receipts.push(id) + return jsonResponse({ + billing_events: + id === 'exact' + ? [ + { + request_id: id, + endpoint_id: 'fal-ai/flux', + cost_estimate_nano_usd: 90000000, + }, + ] + : [], + }) + } + const key = new Headers(init?.headers).get('Authorization') || '' + estimates.push(key) + return jsonResponse({ total_cost: key === 'Key account-a' ? accountCost : 0.3 }) + }) + const request = (requestId: string, apiKey = 'account-a', endpointId = 'fal-ai/flux') => + lookup({ apiKey, endpointId, requestId }) + const burst = await Promise.all(Array.from({ length: 20 }, (_, i) => request(`job-${i}`))) + expect( + burst.every((cost) => cost.source === 'historical_estimate' && cost.costDollars === 0) + ).toBe(true) + expect(estimates).toEqual(['Key account-a']) + accountCost = 0.6 + expect(await request('cached')).toMatchObject({ costDollars: 0, source: 'historical_estimate' }) + expect(await request('exact')).toMatchObject({ + requestId: 'exact', + costDollars: 0.09, + source: 'billing_events', + }) + expect(await request('other-account', 'account-b')).toMatchObject({ costDollars: 0.3 }) + await request('other-model', 'account-a', 'fal-ai/nano-banana') + expect(estimates).toEqual(['Key account-a', 'Key account-b', 'Key account-a']) + expect(new Set(receipts).size).toBe(24) + await vi.advanceTimersByTimeAsync(300001) + expect(await request('expired')).toMatchObject({ + costDollars: 0.6, + source: 'historical_estimate', + }) + expect(estimates).toHaveLength(4) + }) + + it('honors account-scoped Retry-After across endpoints and resumes after expiry', async () => { + const estimates: string[] = [] + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + if (String(url).includes('billing-events')) return jsonResponse({ billing_events: [] }) + const key = new Headers(init?.headers).get('Authorization') || '' + estimates.push(key) + return estimates.length === 1 + ? new Response('sensitive provider body', { + status: 429, + headers: { 'Retry-After': '120' }, + }) + : jsonResponse({ total_cost: 0.12 }) + }) + const request = (endpointId: string, apiKey = 'limited-key') => + lookup({ apiKey, endpointId, requestId: endpointId }) + const limited = await request('fal-ai/flux') + expect(limited.source).toBe('fallback_floor') + expect(limited.error).not.toContain('sensitive provider body') + await request('fal-ai/nano-banana') + expect(estimates).toHaveLength(1) + expect(await request('fal-ai/flux', 'other-key')).toMatchObject({ costDollars: 0.12 }) + await vi.advanceTimersByTimeAsync(119000) + await request('fal-ai/flux') + expect(estimates).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1001) + expect(await request('fal-ai/flux')).toMatchObject({ + source: 'historical_estimate', + costDollars: 0.12, + }) + expect(estimates).toHaveLength(3) + }) + + it('does not shorten an active cooldown when concurrent endpoints receive different Retry-After values', async () => { + let estimates = 0 + const second = createDeferred() + const started = createDeferred() + vi.stubGlobal('fetch', async (url: string | URL) => { + if (String(url).includes('billing-events')) return jsonResponse({ billing_events: [] }) + estimates++ + if (estimates === 1) + return new Response(null, { status: 429, headers: { 'Retry-After': '120' } }) + if (estimates === 2) { + started.resolve() + return second.promise + } + return jsonResponse({ total_cost: 0.2 }) + }) + const request = (endpointId: string) => + lookup({ apiKey: 'shared-account', endpointId, requestId: endpointId }) + const firstLookup = request('fal-ai/flux') + const secondLookup = request('fal-ai/nano-banana') + await started.promise + await firstLookup + second.resolve(new Response(null, { status: 429, headers: { 'Retry-After': '1' } })) + await secondLookup + await vi.advanceTimersByTimeAsync(2000) + expect(await request('fal-ai/other')).toMatchObject({ source: 'fallback_floor' }) + expect(estimates).toBe(2) + await vi.advanceTimersByTimeAsync(118001) + expect(await request('fal-ai/other')).toMatchObject({ + source: 'historical_estimate', + costDollars: 0.2, + }) + }) + + it('backs off forbidden billing-event reads without blocking historical estimates', async () => { + let reads = 0 + vi.stubGlobal('fetch', async (url: string | URL) => { + if (String(url).includes('billing-events')) { + reads++ + return new Response(null, { status: 403 }) + } + return jsonResponse({ total_cost: 0.2 }) + }) + for (const requestId of ['one', 'two']) { + expect( + await lookup({ apiKey: 'limited-scope', endpointId: 'fal-ai/flux', requestId }) + ).toMatchObject({ costDollars: 0.2 }) + } + expect(reads).toBe(1) + }) - it('never bills an audio clip at the video floor', () => { - expect(FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS).toBeLessThan( - FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS + it('never bills a receipt for a different request', async () => { + vi.stubGlobal('fetch', async (url: string | URL) => + String(url).includes('billing-events') + ? jsonResponse({ + billing_events: [ + { + request_id: 'unrelated', + endpoint_id: 'fal-ai/flux', + cost_estimate_nano_usd: 8000000000, + }, + ], + }) + : jsonResponse({ total_cost: 0.1 }) + ) + expect( + await lookup({ apiKey: 'key', endpointId: 'fal-ai/flux', requestId: 'mine' }) + ).toMatchObject({ requestId: 'mine', source: 'historical_estimate', costDollars: 0.1 }) + }) + + it('retains a successful shared price after its only waiter stops', async () => { + const response = createDeferred() + const started = createDeferred() + let estimates = 0 + vi.stubGlobal('fetch', async (url: string | URL) => { + if (String(url).includes('billing-events')) return jsonResponse({ billing_events: [] }) + estimates++ + started.resolve() + return estimates === 1 ? response.promise : jsonResponse({ total_cost: 0.9 }) + }) + const controller = new AbortController() + const args = { apiKey: 'stopped-account', endpointId: 'fal-ai/flux' } + const stopped = lookup({ ...args, requestId: 'stopped', signal: controller.signal }) + const rejected = expect(stopped).rejects.toThrow('Stopped') + await started.promise + controller.abort(new Error('Stopped')) + await rejected + response.resolve(jsonResponse({ total_cost: 0.2 })) + await vi.advanceTimersByTimeAsync(0) + expect(await lookup({ ...args, requestId: 'later' })).toMatchObject({ costDollars: 0.2 }) + expect(estimates).toBe(1) + }) + + it('isolates a cancelled waiter from a shared estimate and bounds hung producers', async () => { + const pending = createDeferred() + const started = createDeferred() + let estimates = 0 + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + if (String(url).includes('billing-events')) return jsonResponse({ billing_events: [] }) + estimates++ + started.resolve() + init?.signal?.addEventListener('abort', () => pending.reject(init.signal?.reason), { + once: true, + }) + return pending.promise + }) + const controller = new AbortController() + const args = { apiKey: 'key', endpointId: 'fal-ai/flux' } + const cancelled = lookup({ ...args, requestId: 'cancelled', signal: controller.signal }) + const rejected = expect(cancelled).rejects.toThrow('Stopped') + const survivor = lookup({ ...args, requestId: 'survivor' }) + await started.promise + controller.abort(new Error('Stopped')) + await rejected + pending.resolve(jsonResponse({ total_cost: 0.2 })) + expect(await survivor).toMatchObject({ costDollars: 0.2 }) + expect(estimates).toBe(1) + + const lateResponse = createDeferred() + vi.stubGlobal('fetch', async (url: string | URL) => + String(url).includes('billing-events') + ? jsonResponse({ billing_events: [] }) + : lateResponse.promise + ) + const hung = lookup({ ...args, endpointId: 'fal-ai/hung', requestId: 'hung' }) + await vi.advanceTimersByTimeAsync(10001) + expect(await hung).toMatchObject({ source: 'fallback_floor' }) + vi.stubGlobal('fetch', async (url: string | URL) => + String(url).includes('billing-events') + ? jsonResponse({ billing_events: [] }) + : jsonResponse({ total_cost: 0.4 }) ) + expect( + await lookup({ ...args, endpointId: 'fal-ai/hung', requestId: 'recovered' }) + ).toMatchObject({ costDollars: 0.4 }) + lateResponse.resolve(jsonResponse({ total_cost: 0.9 })) + await vi.advanceTimersByTimeAsync(0) + expect( + await lookup({ ...args, endpointId: 'fal-ai/hung', requestId: 'after-late' }) + ).toMatchObject({ costDollars: 0.4 }) }) }) diff --git a/apps/sim/lib/tools/falai-pricing.ts b/apps/sim/lib/tools/falai-pricing.ts index 821f7169e8c..c5f2e54dd75 100644 --- a/apps/sim/lib/tools/falai-pricing.ts +++ b/apps/sim/lib/tools/falai-pricing.ts @@ -1,13 +1,13 @@ +import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { interruptibleSleep } from '@sim/utils/helpers' import { isRecordLike } from '@sim/utils/object' +import { parseRetryAfter } from '@sim/utils/retry' +import { LRUCache } from 'lru-cache' +import { coalesceLocally } from '@/lib/concurrency/singleflight' import { DeadlineExceededError, withinDeadline } from '@/lib/core/utils/deadline' -import { - DEFAULT_MAX_ERROR_BODY_BYTES, - readResponseJsonWithLimit, - readResponseTextWithLimit, -} from '@/lib/core/utils/stream-limits' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' export const FALAI_HOSTED_KEY_MARKUP_MULTIPLIER = 1.5 export const FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS = 0.05 @@ -18,10 +18,39 @@ const FALAI_BILLING_EVENT_RETRY_MS = 500 const FALAI_PRICING_RESPONSE_MAX_BYTES = 2 * 1024 * 1024 /** * Longest a cost lookup may take, covering every billing-event attempt and the estimate. The - * generation is already paid for by then, so a lookup that runs out falls back to the floor. + * queue is terminal by then; successful delivery can fall back to an explicitly labeled estimate. */ -const FALAI_COST_LOOKUP_TIMEOUT_MS = 10_000 +export const FALAI_COST_LOOKUP_TIMEOUT_MS = 10_000 const logger = createLogger('FalAIPricing') +const FALAI_ESTIMATE_TTL_MS = 5 * 60_000 +const FALAI_COOLDOWN_MS = 60_000 +const FALAI_MAX_COOLDOWN_MS = 5 * 60_000 +const estimateCache = new LRUCache({ max: 4096, ttl: FALAI_ESTIMATE_TTL_MS }) +const lookupCooldowns = new LRUCache({ max: 2048 }) + +function credentialFingerprint(apiKey: string): string { + return createHash('sha256').update(apiKey).digest('hex') +} + +function platformError(response: Response, cooldownKey: string, operation: string): string { + const error = `Fal.ai ${operation} failed: HTTP ${response.status}` + if (response.status === 429 || response.status === 401 || response.status === 403) { + const ttl = Math.max( + 1000, + lookupCooldowns.getRemainingTTL(cooldownKey), + parseRetryAfter(response.headers.get('retry-after'), FALAI_MAX_COOLDOWN_MS) ?? + FALAI_COOLDOWN_MS + ) + lookupCooldowns.set(cooldownKey, error, { ttl }) + logger.warn('Fal.ai cost lookup cooling down', { + operation, + status: response.status, + retryAfterMs: ttl, + }) + } + void response.body?.cancel().catch(() => undefined) + return error +} export interface FalAICostMetadata { endpointId: string @@ -85,7 +114,7 @@ function parseBillingEvent(value: unknown): FalAIBillingEvent | undefined { const costEstimateNanoUsd = getNumber(value.cost_estimate_nano_usd) if (typeof requestId !== 'string' || typeof endpointId !== 'string') return undefined - if (costEstimateNanoUsd === undefined) return undefined + if (costEstimateNanoUsd === undefined || costEstimateNanoUsd < 0) return undefined return { request_id: requestId, @@ -101,7 +130,9 @@ async function fetchFalAIBillingEvent( apiKey: string, requestId: string, signal?: AbortSignal -): Promise { +): Promise<{ event?: FalAIBillingEvent; retry: boolean }> { + const cooldownKey = `billing:${credentialFingerprint(apiKey)}` + if (lookupCooldowns.has(cooldownKey)) return { retry: false } const url = new URL('https://api.fal.ai/v1/models/billing-events') url.searchParams.set('request_id', requestId) url.searchParams.set('limit', '1') @@ -120,14 +151,18 @@ async function fetchFalAIBillingEvent( requestId, error: getErrorMessage(error, 'Unknown error'), }) - return undefined + return { retry: true } } - if (!response.ok) return undefined + if (!response.ok) { + platformError(response, cooldownKey, 'billing event lookup') + return { retry: response.status >= 500 } + } const data = await readResponseJsonWithLimit(response, { maxBytes: FALAI_PRICING_RESPONSE_MAX_BYTES, label: 'Fal.ai billing event response', + signal, }).catch((error) => { signal?.throwIfAborted() logger.warn('Failed to parse Fal.ai billing event response', { @@ -136,14 +171,18 @@ async function fetchFalAIBillingEvent( }) return undefined }) - if (!isRecordLike(data) || !Array.isArray(data.billing_events)) return undefined + if (!isRecordLike(data) || !Array.isArray(data.billing_events)) return { retry: true } - return data.billing_events.map(parseBillingEvent).find(Boolean) + const event = data.billing_events + .map(parseBillingEvent) + .find((entry) => entry?.request_id === requestId) + return { event, retry: true } } async function estimateFalAICallCost( apiKey: string, endpointId: string, + cooldownKey: string, signal?: AbortSignal ): Promise<{ costDollars?: number; error?: string }> { let response: Response @@ -170,11 +209,7 @@ async function estimateFalAICallCost( } if (!response.ok) { - const error = await readResponseTextWithLimit(response, { - maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, - label: 'Fal.ai pricing estimate error response', - }).catch(() => '') - return { error: `Fal.ai pricing estimate failed: ${response.status} ${error}` } + return { error: platformError(response, cooldownKey, 'pricing estimate') } } let data: unknown @@ -182,42 +217,85 @@ async function estimateFalAICallCost( data = await readResponseJsonWithLimit(response, { maxBytes: FALAI_PRICING_RESPONSE_MAX_BYTES, label: 'Fal.ai pricing estimate response', + signal, }) } catch (error) { signal?.throwIfAborted() return { error: getErrorMessage(error, 'Unknown error') } } const totalCost = isRecordLike(data) ? getNumber(data.total_cost) : undefined - if (totalCost === undefined) { + if (totalCost === undefined || totalCost < 0) { return { error: 'Fal.ai pricing estimate missing total_cost' } } return { costDollars: totalCost } } +/** Historical prices are account-specific; exact job receipts always bypass this process-local cache. */ +async function getHistoricalEstimate(apiKey: string, endpointId: string, signal?: AbortSignal) { + const fingerprint = credentialFingerprint(apiKey) + const cacheKey = `${fingerprint}:${endpointId}` + const cached = estimateCache.get(cacheKey) + if (cached !== undefined) return { costDollars: cached } + const cooldownKey = `estimate:${fingerprint}` + const cooldown = lookupCooldowns.get(cooldownKey) + if (cooldown) return { error: cooldown } + + // One caller stopping must not cancel the estimate shared by other completed jobs. + const deadlineAt = Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS + const estimate = await coalesceLocally(`fal-estimate:${cacheKey}`, async () => { + const result = await withinDeadline( + (producerSignal) => estimateFalAICallCost(apiKey, endpointId, cooldownKey, producerSignal), + deadlineAt + ) + if (result.costDollars !== undefined) estimateCache.set(cacheKey, result.costDollars) + return result + }) + signal?.throwIfAborted() + return estimate +} + interface FalAICostLookup { apiKey: string endpointId: string requestId: string /** Ends the lookup with an error. Without one, only {@link FALAI_COST_LOOKUP_TIMEOUT_MS} ends it. */ signal?: AbortSignal + deadlineAt?: number } /** * The cost of a completed fal request. The lookup is bounded on its own: past * {@link FALAI_COST_LOOKUP_TIMEOUT_MS} it returns the fallback floor rather than nothing. */ -export async function getFalAICostMetadata(lookup: FalAICostLookup): Promise { +export function getFalAICostMetadata(lookup: FalAICostLookup): Promise { + return withCostDeadline(lookup, lookUpFalAICost) +} + +/** Estimates a request whose exact receipt has already been checked, sharing that lookup's deadline. */ +export async function getFalAIEstimatedCostMetadata( + lookup: FalAICostLookup +): Promise { + lookup.signal?.throwIfAborted() + const cached = estimateCache.get(`${credentialFingerprint(lookup.apiKey)}:${lookup.endpointId}`) + if (cached !== undefined) return historicalCost(lookup, cached) + return withCostDeadline(lookup, lookUpEstimatedCost) +} + +async function withCostDeadline( + lookup: FalAICostLookup, + operation: (lookup: FalAICostLookup) => Promise +): Promise { const { endpointId, requestId } = lookup try { return await withinDeadline( - (signal) => lookUpFalAICost({ ...lookup, signal }), - Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS, + (signal) => operation({ ...lookup, signal }), + lookup.deadlineAt ?? Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS, lookup.signal ) } catch (error) { if (!(error instanceof DeadlineExceededError)) throw error - logger.warn('Fal.ai cost lookup timed out after generation completed', { + logger.warn('Fal.ai cost lookup timed out after queue completion', { endpointId, requestId, }) @@ -240,14 +318,33 @@ function fallbackFloorCost( } } -async function lookUpFalAICost({ +/** Reads only an exact receipt, without inventing a charge for a failed result. */ +export async function getFalAIBillingReceipt( + lookup: FalAICostLookup +): Promise { + try { + return await withinDeadline( + (signal) => lookUpBillingReceipt({ ...lookup, signal }), + lookup.deadlineAt ?? Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS, + lookup.signal + ) + } catch (error) { + if (!(error instanceof DeadlineExceededError)) throw error + logger.warn('Fal.ai billing receipt lookup timed out', { + endpointId: lookup.endpointId, + requestId: lookup.requestId, + }) + return undefined + } +} + +async function lookUpBillingReceipt({ apiKey, - endpointId, requestId, signal, -}: FalAICostLookup): Promise { +}: FalAICostLookup): Promise { for (let attempt = 0; attempt < FALAI_BILLING_EVENT_ATTEMPTS; attempt++) { - const event = await fetchFalAIBillingEvent(apiKey, requestId, signal) + const { event, retry } = await fetchFalAIBillingEvent(apiKey, requestId, signal) if (event) { return { endpointId: event.endpoint_id, @@ -261,24 +358,33 @@ async function lookUpFalAICost({ } } + if (!retry) break if (attempt < FALAI_BILLING_EVENT_ATTEMPTS - 1) { await interruptibleSleep(FALAI_BILLING_EVENT_RETRY_MS, signal) signal?.throwIfAborted() } } +} - const estimate = await estimateFalAICallCost(apiKey, endpointId, signal) - if (estimate.costDollars !== undefined) { - return { - endpointId, - requestId, - costDollars: estimate.costDollars, - source: 'historical_estimate', - currency: 'USD', - } - } +async function lookUpFalAICost(lookup: FalAICostLookup): Promise { + const receipt = await lookUpBillingReceipt(lookup) + if (receipt) return receipt + return lookUpEstimatedCost(lookup) +} + +function historicalCost( + { endpointId, requestId }: FalAICostLookup, + costDollars: number +): FalAICostMetadata { + return { endpointId, requestId, costDollars, source: 'historical_estimate', currency: 'USD' } +} + +async function lookUpEstimatedCost(lookup: FalAICostLookup): Promise { + const { apiKey, endpointId, requestId, signal } = lookup + const estimate = await getHistoricalEstimate(apiKey, endpointId, signal) + if (estimate.costDollars !== undefined) return historicalCost(lookup, estimate.costDollars) - logger.warn('Fal.ai cost metadata unavailable after generation completed', { + logger.warn('Fal.ai cost metadata unavailable after queue completion', { endpointId, requestId, error: estimate.error, diff --git a/scripts/check-source-conventions.baseline.json b/scripts/check-source-conventions.baseline.json index e2b45dc1e08..557ea8e463b 100644 --- a/scripts/check-source-conventions.baseline.json +++ b/scripts/check-source-conventions.baseline.json @@ -271,7 +271,6 @@ "apps/sim/lib/pptx-renderer/shapes/custom-geometry.ts": 1, "apps/sim/lib/pptx-renderer/shapes/presets.ts": 1, "apps/sim/lib/table/workflow-columns.ts": 1, - "apps/sim/lib/tools/falai-pricing.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/file-manager-errors.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/file-manager.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts": 1,