diff --git a/apps/bubble-studio/public/integrations/fal-ai.png b/apps/bubble-studio/public/integrations/fal-ai.png new file mode 100644 index 00000000..f6db2f7b Binary files /dev/null and b/apps/bubble-studio/public/integrations/fal-ai.png differ diff --git a/apps/bubble-studio/src/lib/integrations.ts b/apps/bubble-studio/src/lib/integrations.ts index 69be0cec..a7504c88 100644 --- a/apps/bubble-studio/src/lib/integrations.ts +++ b/apps/bubble-studio/src/lib/integrations.ts @@ -24,7 +24,9 @@ export const SERVICE_LOGOS: Readonly> = Object.freeze({ Instagram: '/integrations/instagram.svg', Apify: '/integrations/apify.svg', GitHub: '/integrations/github.svg', - ElevenLabs: '/integrations/elevenlabs.png', // Placeholder path + ElevenLabs: '/integrations/elevenlabs.png', + 'fal.ai': '/integrations/fal-ai.png', + 'Fal AI': '/integrations/fal-ai.png', 'Follow Up Boss': '/integrations/FUB.png', 'AGI Inc': '/integrations/agi-inc.svg', Telegram: '/integrations/telegram.svg', @@ -68,6 +70,7 @@ export const INTEGRATIONS: IntegrationLogo[] = [ { name: 'Apify', file: SERVICE_LOGOS['Apify'] }, { name: 'GitHub', file: SERVICE_LOGOS['GitHub'] }, { name: 'ElevenLabs', file: SERVICE_LOGOS['ElevenLabs'] }, + { name: 'Fal AI', file: SERVICE_LOGOS['Fal AI'] }, { name: 'Follow Up Boss', file: SERVICE_LOGOS['Follow Up Boss'] }, { name: 'Telegram', file: SERVICE_LOGOS['Telegram'] }, { name: 'Airtable', file: SERVICE_LOGOS['Airtable'] }, @@ -124,6 +127,9 @@ const NAME_ALIASES: Readonly> = Object.freeze({ github: 'GitHub', elevenlabs: 'ElevenLabs', 'eleven-labs': 'ElevenLabs', + falai: 'Fal AI', + 'fal-ai': 'Fal AI', + fal: 'Fal AI', followupboss: 'Follow Up Boss', fub: 'Follow Up Boss', 'follow-up-boss': 'Follow Up Boss', @@ -226,6 +232,7 @@ export function findLogoForBubble( [/\bapify\b/, 'Apify'], [/\bgithub\b/, 'GitHub'], [/\belevenlabs\b|\beleven-labs\b/, 'ElevenLabs'], + [/\bfal\s*\.?\s*ai\b|\bfalai\b|\bfal-ai\b/, 'Fal AI'], [/\bfollow\s*up\s*boss\b|\bfollowupboss\b|\bfub\b/, 'Follow Up Boss'], [/\bagi\s*inc\b|\bagi-inc\b|\bagiinc\b/, 'AGI Inc'], [/\btelegram\b/, 'Telegram'], diff --git a/apps/bubble-studio/src/pages/CredentialsPage.tsx b/apps/bubble-studio/src/pages/CredentialsPage.tsx index 0da50d6d..55035ade 100644 --- a/apps/bubble-studio/src/pages/CredentialsPage.tsx +++ b/apps/bubble-studio/src/pages/CredentialsPage.tsx @@ -234,6 +234,14 @@ const CREDENTIAL_TYPE_CONFIG: Record = { namePlaceholder: 'My Airtable Token', credentialConfigurations: {}, }, + [CredentialType.FAL_AI_API_KEY]: { + label: 'Fal AI', + description: + 'API key for Fal AI media generation services (text-to-image, image-to-image)', + placeholder: 'your-fal-ai-api-key', + namePlaceholder: 'My Fal AI Key', + credentialConfigurations: {}, + }, [CredentialType.INSFORGE_BASE_URL]: { label: 'InsForge Base URL', description: @@ -297,6 +305,7 @@ const getServiceNameForCredentialType = ( [CredentialType.ELEVENLABS_API_KEY]: 'ElevenLabs', [CredentialType.AIRTABLE_CRED]: 'Airtable', [CredentialType.NOTION_OAUTH_TOKEN]: 'Notion', + [CredentialType.FAL_AI_API_KEY]: 'Fal AI', [CredentialType.INSFORGE_BASE_URL]: 'InsForge', [CredentialType.INSFORGE_API_KEY]: 'InsForge', }; diff --git a/apps/bubblelab-api/src/services/credential-validator.ts b/apps/bubblelab-api/src/services/credential-validator.ts index bb4438b9..c57fa23b 100644 --- a/apps/bubblelab-api/src/services/credential-validator.ts +++ b/apps/bubblelab-api/src/services/credential-validator.ts @@ -193,6 +193,11 @@ export class CredentialValidator { case CredentialType.NOTION_OAUTH_TOKEN: baseParams.operation = 'list_users'; break; + case CredentialType.FAL_AI_API_KEY: + baseParams.operation = 'text_to_image'; + baseParams.model = 'fal-ai/flux/dev'; + baseParams.prompt = 'test'; + break; default: break; } diff --git a/packages/bubble-core/src/bubble-factory.ts b/packages/bubble-core/src/bubble-factory.ts index 2ce6d73d..6e1f2547 100644 --- a/packages/bubble-core/src/bubble-factory.ts +++ b/packages/bubble-core/src/bubble-factory.ts @@ -154,6 +154,7 @@ export class BubbleFactory { 'agi-inc', 'airtable', 'notion', + 'fal-ai', 'firecrawl', 'insforge-db', ]; @@ -276,6 +277,9 @@ export class BubbleFactory { const { AirtableBubble } = await import( './bubbles/service-bubble/airtable.js' ); + const { FalAiBubble } = await import( + './bubbles/service-bubble/fal-ai.js' + ); const { FirecrawlBubble } = await import( './bubbles/service-bubble/firecrawl.js' ); @@ -383,6 +387,7 @@ export class BubbleFactory { this.register('eleven-labs', ElevenLabsBubble as BubbleClassWithMetadata); this.register('agi-inc', AGIIncBubble as BubbleClassWithMetadata); this.register('airtable', AirtableBubble as BubbleClassWithMetadata); + this.register('fal-ai', FalAiBubble as BubbleClassWithMetadata); this.register('firecrawl', FirecrawlBubble as BubbleClassWithMetadata); this.register('insforge-db', InsForgeDbBubble as BubbleClassWithMetadata); @@ -674,6 +679,7 @@ import { ApifyBubble, // bubble name: 'apify' ElevenLabsBubble, // bubble name: 'eleven-labs' FollowUpBossBubble, // bubble name: 'followupboss' + FalAiBubble, // bubble name: 'fal-ai' // Tool Bubbles (Perform useful actions) ResearchAgentTool, // bubble name: 'research-agent-tool' diff --git a/packages/bubble-core/src/bubbles/service-bubble/fal-ai.test.ts b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.test.ts new file mode 100644 index 00000000..046fb863 --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.test.ts @@ -0,0 +1,1346 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { FalAiBubble } from './fal-ai.js'; +import { CredentialType } from '@bubblelab/shared-schemas'; + +// Mock fetch +const globalFetch = global.fetch; +const mockFetch = vi.fn(); + +describe('FalAiBubble', () => { + let bubble: FalAiBubble; + const mockApiKey = 'fal-mock-api-key'; + + beforeEach(() => { + global.fetch = mockFetch; + vi.useFakeTimers(); + }); + + afterEach(() => { + global.fetch = globalFetch; + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + describe('basic properties', () => { + it('should have correct static properties', () => { + expect(FalAiBubble.bubbleName).toBe('fal-ai'); + expect(FalAiBubble.service).toBe('fal-ai'); + expect(FalAiBubble.authType).toBe('apikey'); + expect(FalAiBubble.type).toBe('service'); + expect(FalAiBubble.alias).toBe('falai'); + expect(FalAiBubble.shortDescription).toContain('fal.ai'); + }); + + it('should have longDescription with use cases', () => { + const bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + }); + expect(bubble.longDescription).toContain('media generation'); + expect(bubble.longDescription).toContain('Text-to-image'); + }); + }); + + describe('text_to_image', () => { + it('should successfully generate an image with waitForResult=true', async () => { + const mockRequestId = 'req-123'; + const mockImageUrl = 'https://fal.ai/files/image.png'; + + // Mock initial request submission + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }); + + // Mock status check (IN_PROGRESS) - first poll + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'IN_PROGRESS' }), + }); + + // Mock status check (COMPLETED) - second poll + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'COMPLETED' }), + }); + + // Mock result retrieval + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: mockImageUrl }], + status: 'COMPLETED', + }), + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'A beautiful sunset', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const resultPromise = bubble.performAction(); + + // Advance timers to allow polling to complete + await vi.advanceTimersByTimeAsync(2000); + + const result = await resultPromise; + + expect(result).toEqual({ + operation: 'text_to_image', + imageUrl: mockImageUrl, + imageUrls: [mockImageUrl], + status: 'COMPLETED', + success: true, + error: '', + }); + + // Verify API calls + expect(mockFetch).toHaveBeenCalledWith( + 'https://queue.fal.run/fal-ai/fal-ai/flux/dev', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: `Key ${mockApiKey}`, + 'Content-Type': 'application/json', + }), + }) + ); + }); + + it('should return request_id immediately when waitForResult=false', async () => { + const mockRequestId = 'req-456'; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'A beautiful sunset', + waitForResult: false, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'text_to_image', + requestId: mockRequestId, + success: true, + error: '', + }); + + // Should only call once (no polling) + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should handle API errors', async () => { + const errorMessage = 'Invalid API key'; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => errorMessage, + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'text_to_image', + success: false, + error: expect.stringContaining('API request failed'), + }); + }); + + it('should handle missing credentials', async () => { + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'text_to_image', + success: false, + error: 'fal.ai API key is required', + }); + }); + + it('should apply default values', async () => { + const mockRequestId = 'req-default'; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + waitForResult: false, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + await bubble.performAction(); + + const requestBody = JSON.parse( + mockFetch.mock.calls[0][1]?.body as string + ); + expect(requestBody.image_size).toBe('square_hd'); + expect(requestBody.num_images).toBe(1); + expect(requestBody.enable_safety_checker).toBe(true); + }); + + it('should handle multiple images', async () => { + const mockRequestId = 'req-multi'; + const mockImageUrls = [ + 'https://fal.ai/files/image1.png', + 'https://fal.ai/files/image2.png', + ]; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'COMPLETED' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: mockImageUrls.map((url) => ({ url })), + status: 'COMPLETED', + }), + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + numImages: 2, + waitForResult: true, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const resultPromise = bubble.performAction(); + await vi.advanceTimersByTimeAsync(2000); + const result = await resultPromise; + + expect(result.imageUrls).toEqual(mockImageUrls); + expect(result.imageUrl).toBe(mockImageUrls[0]); + }); + + it('should handle polling timeout', async () => { + const mockRequestId = 'req-timeout'; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }) + .mockResolvedValue({ + ok: true, + json: async () => ({ status: 'IN_PROGRESS' }), + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + waitForResult: true, + maxWaitTime: 1000, // 1 second timeout + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const resultPromise = bubble.performAction(); + + // Fast-forward time to trigger timeout (need to go past maxWaitTime) + await vi.advanceTimersByTimeAsync(2000); + + const result = await resultPromise; + + expect(result.success).toBe(false); + expect(result.error).toContain('timeout'); + }); + }); + + describe('image_to_image', () => { + it('should successfully transform an image', async () => { + const mockRequestId = 'req-img2img'; + const mockImageUrl = 'https://fal.ai/files/transformed.png'; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'COMPLETED' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: mockImageUrl }], + status: 'COMPLETED', + }), + }); + + bubble = new FalAiBubble({ + operation: 'image_to_image', + model: 'fal-ai/flux/dev', + prompt: 'Make it more colorful', + imageUrl: 'https://example.com/image.jpg', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const resultPromise = bubble.performAction(); + await vi.advanceTimersByTimeAsync(2000); + const result = await resultPromise; + + expect(result).toEqual({ + operation: 'image_to_image', + imageUrl: mockImageUrl, + imageUrls: [mockImageUrl], + status: 'COMPLETED', + success: true, + error: '', + }); + + const requestBody = JSON.parse( + mockFetch.mock.calls[0][1]?.body as string + ); + expect(requestBody.image_url).toBe('https://example.com/image.jpg'); + expect(requestBody.prompt).toBe('Make it more colorful'); + expect(requestBody.strength).toBe(0.8); // Default value + }); + + it('should handle missing credentials', async () => { + bubble = new FalAiBubble({ + operation: 'image_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + imageUrl: 'https://example.com/image.jpg', + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'image_to_image', + success: false, + error: 'fal.ai API key is required', + }); + }); + }); + + describe('get_status', () => { + it('should successfully get job status', async () => { + const mockRequestId = 'req-status'; + const mockStatus = 'IN_PROGRESS'; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: mockStatus }), + }); + + bubble = new FalAiBubble({ + operation: 'get_status', + requestId: mockRequestId, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'get_status', + status: mockStatus, + success: true, + error: '', + }); + + expect(mockFetch).toHaveBeenCalledWith( + `https://queue.fal.run/${mockRequestId}/status`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: `Key ${mockApiKey}`, + }), + }) + ); + }); + + it('should handle API errors when getting status', async () => { + const mockRequestId = 'req-invalid'; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => 'Request not found', + }); + + bubble = new FalAiBubble({ + operation: 'get_status', + requestId: mockRequestId, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'get_status', + status: 'FAILED', + success: false, + error: expect.stringContaining('Failed to get status'), + }); + }); + + it('should handle missing credentials', async () => { + bubble = new FalAiBubble({ + operation: 'get_status', + requestId: 'req-123', + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'get_status', + status: 'FAILED', + success: false, + error: 'fal.ai API key is required', + }); + }); + }); + + describe('get_result', () => { + it('should successfully get job result', async () => { + const mockRequestId = 'req-result'; + const mockImageUrl = 'https://fal.ai/files/result.png'; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: mockImageUrl }], + status: 'COMPLETED', + }), + }); + + bubble = new FalAiBubble({ + operation: 'get_result', + requestId: mockRequestId, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'get_result', + imageUrl: mockImageUrl, + imageUrls: [mockImageUrl], + status: 'COMPLETED', + success: true, + error: '', + }); + + expect(mockFetch).toHaveBeenCalledWith( + `https://queue.fal.run/${mockRequestId}`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: `Key ${mockApiKey}`, + }), + }) + ); + }); + + it('should handle single image format', async () => { + const mockRequestId = 'req-single'; + const mockImageUrl = 'https://fal.ai/files/single.png'; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + image: { url: mockImageUrl }, + status: 'COMPLETED', + }), + }); + + bubble = new FalAiBubble({ + operation: 'get_result', + requestId: mockRequestId, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.imageUrl).toBe(mockImageUrl); + }); + + it('should handle multiple images in result', async () => { + const mockRequestId = 'req-multi-result'; + const mockImageUrls = [ + 'https://fal.ai/files/result1.png', + 'https://fal.ai/files/result2.png', + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: mockImageUrls.map((url) => ({ url })), + status: 'COMPLETED', + }), + }); + + bubble = new FalAiBubble({ + operation: 'get_result', + requestId: mockRequestId, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.imageUrls).toEqual(mockImageUrls); + expect(result.imageUrl).toBe(mockImageUrls[0]); + }); + + it('should handle API errors when getting result', async () => { + const mockRequestId = 'req-error'; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => 'Result not found', + }); + + bubble = new FalAiBubble({ + operation: 'get_result', + requestId: mockRequestId, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'get_result', + success: false, + error: expect.stringContaining('Failed to get result'), + }); + }); + + it('should handle missing credentials', async () => { + bubble = new FalAiBubble({ + operation: 'get_result', + requestId: 'req-123', + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'get_result', + success: false, + error: 'fal.ai API key is required', + }); + }); + }); + + describe('testCredential', () => { + it('should return true for valid credentials', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 404, // 404 is OK (invalid request ID, but valid auth) + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.testCredential(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://queue.fal.run/test-request-id/status', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: `Key ${mockApiKey}`, + }), + }) + ); + expect(result).toBe(true); + }); + + it('should return false for invalid credentials (401)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + credentials: { + [CredentialType.FAL_AI_API_KEY]: 'invalid-key', + }, + }); + + const result = await bubble.testCredential(); + expect(result).toBe(false); + }); + + it('should return false if no API key provided', async () => { + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + }); + + const result = await bubble.testCredential(); + expect(result).toBe(false); + }); + + it('should return false if fetch fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.testCredential(); + expect(result).toBe(false); + }); + }); + + describe('polling logic', () => { + it('should poll with exponential backoff', async () => { + const mockRequestId = 'req-poll'; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'IN_QUEUE' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'IN_PROGRESS' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'COMPLETED' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: 'https://fal.ai/files/polled.png' }], + status: 'COMPLETED', + }), + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + waitForResult: true, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const resultPromise = bubble.performAction(); + + // Advance timers to simulate polling delays + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(4000); + + const result = await resultPromise; + + expect(result.success).toBe(true); + // Should have called status endpoint multiple times + expect(mockFetch).toHaveBeenCalledTimes(5); // 1 submit + 3 status + 1 result + }); + + it('should handle FAILED status', async () => { + const mockRequestId = 'req-failed'; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'FAILED' }), + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + waitForResult: true, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(false); + expect(result.error).toContain('failed'); + }); + }); + + describe('error handling', () => { + it('should handle network errors gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('should handle unknown errors', async () => { + mockFetch.mockRejectedValueOnce('Unknown error'); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unknown error occurred'); + }); + }); + + describe('action() method integration', () => { + it('should work when calling .action() inherited from BaseBubble', async () => { + const mockRequestId = 'req-action'; + const mockImageUrl = 'https://fal.ai/files/action.png'; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ request_id: mockRequestId }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'COMPLETED' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: mockImageUrl }], + status: 'COMPLETED', + }), + }); + + bubble = new FalAiBubble({ + operation: 'text_to_image', + model: 'fal-ai/flux/dev', + prompt: 'test', + waitForResult: true, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + // Call .action() instead of .performAction() + const resultPromise = bubble.action(); + await vi.advanceTimersByTimeAsync(2000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + operation: 'text_to_image', + imageUrl: mockImageUrl, + imageUrls: [mockImageUrl], + status: 'COMPLETED', + success: true, + error: '', + }); + }); + }); + + describe('list_models', () => { + it('should successfully list models', async () => { + const mockModels = [ + { + endpoint_id: 'fal-ai/flux/dev', + metadata: { + display_name: 'FLUX.1 [dev]', + category: 'text-to-image', + description: 'Fast text-to-image generation', + status: 'active', + }, + }, + { + endpoint_id: 'fal-ai/flux/schnell', + metadata: { + display_name: 'FLUX.1 [schnell]', + category: 'text-to-image', + status: 'active', + }, + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: mockModels, + has_more: false, + }), + }); + + bubble = new FalAiBubble({ + operation: 'list_models', + limit: 10, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'list_models', + models: mockModels, + has_more: false, + success: true, + error: '', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.fal.ai/v1/models?limit=10', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: `Key ${mockApiKey}`, + }), + }) + ); + }); + + it('should handle pagination with cursor', async () => { + const mockCursor = 'cursor-123'; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [], + has_more: true, + next_cursor: 'cursor-456', + }), + }); + + bubble = new FalAiBubble({ + operation: 'list_models', + limit: 5, + cursor: mockCursor, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(true); + expect(result.has_more).toBe(true); + expect(result.next_cursor).toBe('cursor-456'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`cursor=${mockCursor}`), + expect.any(Object) + ); + }); + + it('should support OpenAPI schema expansion', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [ + { + endpoint_id: 'fal-ai/flux/dev', + openapi: { + openapi: '3.0.4', + paths: {}, + components: {}, + }, + }, + ], + has_more: false, + }), + }); + + bubble = new FalAiBubble({ + operation: 'list_models', + limit: 1, + expand: ['openapi-3.0'], + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('expand=openapi-3.0'), + expect.any(Object) + ); + }); + + it('should support category filtering', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [], + has_more: false, + }), + }); + + bubble = new FalAiBubble({ + operation: 'list_models', + category: 'text-to-image', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + await bubble.performAction(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('category=text-to-image'), + expect.any(Object) + ); + }); + + it('should support status filtering', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [], + has_more: false, + }), + }); + + bubble = new FalAiBubble({ + operation: 'list_models', + status: 'active', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + await bubble.performAction(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('status=active'), + expect.any(Object) + ); + }); + + it('should handle API errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'Server error', + }); + + bubble = new FalAiBubble({ + operation: 'list_models', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'list_models', + success: false, + error: expect.stringContaining('Failed to list models'), + }); + }); + + it('should work without credentials (lower rate limits)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [], + has_more: false, + }), + }); + + bubble = new FalAiBubble({ + operation: 'list_models', + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(true); + // Should not include Authorization header + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.any(String), + }), + }) + ); + }); + }); + + describe('search_models', () => { + it('should successfully search models', async () => { + const mockModels = [ + { + endpoint_id: 'fal-ai/flux/dev', + metadata: { + display_name: 'FLUX.1 [dev]', + category: 'text-to-image', + }, + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: mockModels, + has_more: false, + }), + }); + + bubble = new FalAiBubble({ + operation: 'search_models', + query: 'flux', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'search_models', + models: mockModels, + has_more: false, + success: true, + error: '', + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('q=flux'), + expect.any(Object) + ); + }); + + it('should support combined search and filters', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [], + has_more: false, + }), + }); + + bubble = new FalAiBubble({ + operation: 'search_models', + query: 'video', + category: 'image-to-video', + limit: 5, + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + await bubble.performAction(); + + const callUrl = mockFetch.mock.calls[0][0] as string; + expect(callUrl).toContain('q=video'); + expect(callUrl).toContain('category=image-to-video'); + expect(callUrl).toContain('limit=5'); + }); + + it('should handle search with pagination', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [], + has_more: true, + next_cursor: 'search-cursor-123', + }), + }); + + bubble = new FalAiBubble({ + operation: 'search_models', + query: 'stable diffusion', + cursor: 'prev-cursor', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(true); + expect(result.next_cursor).toBe('search-cursor-123'); + }); + + it('should handle API errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'Invalid query', + }); + + bubble = new FalAiBubble({ + operation: 'search_models', + query: 'test', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'search_models', + success: false, + error: expect.stringContaining('Failed to search models'), + }); + }); + }); + + describe('get_model', () => { + it('should successfully get a single model', async () => { + const mockModel = { + endpoint_id: 'fal-ai/flux/dev', + metadata: { + display_name: 'FLUX.1 [dev]', + category: 'text-to-image', + description: 'Fast, high-quality image generation', + status: 'active', + tags: ['fast', 'pro'], + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [mockModel], + }), + }); + + bubble = new FalAiBubble({ + operation: 'get_model', + endpointId: 'fal-ai/flux/dev', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'get_model', + models: [mockModel], + success: true, + error: '', + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('endpoint_id=fal-ai%2Fflux%2Fdev'), + expect.any(Object) + ); + }); + + it('should support multiple endpoint IDs', async () => { + const mockModels = [ + { endpoint_id: 'fal-ai/flux/dev' }, + { endpoint_id: 'fal-ai/flux/schnell' }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: mockModels, + }), + }); + + bubble = new FalAiBubble({ + operation: 'get_model', + endpointId: ['fal-ai/flux/dev', 'fal-ai/flux/schnell'], + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(true); + expect(result.models).toHaveLength(2); + + const callUrl = mockFetch.mock.calls[0][0] as string; + expect(callUrl).toContain('endpoint_id=fal-ai%2Fflux%2Fdev'); + expect(callUrl).toContain('endpoint_id=fal-ai%2Fflux%2Fschnell'); + }); + + it('should support OpenAPI schema expansion', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [ + { + endpoint_id: 'fal-ai/flux/dev', + openapi: { + openapi: '3.0.4', + paths: { + '/': { + post: { + requestBody: {}, + }, + }, + }, + }, + }, + ], + }), + }); + + bubble = new FalAiBubble({ + operation: 'get_model', + endpointId: 'fal-ai/flux/dev', + expand: ['openapi-3.0'], + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('expand=openapi-3.0'), + expect.any(Object) + ); + }); + + it('should handle model not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => 'Model not found', + }); + + bubble = new FalAiBubble({ + operation: 'get_model', + endpointId: 'fal-ai/nonexistent', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result).toEqual({ + operation: 'get_model', + success: false, + error: expect.stringContaining('Failed to get model'), + }); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network timeout')); + + bubble = new FalAiBubble({ + operation: 'get_model', + endpointId: 'fal-ai/flux/dev', + credentials: { + [CredentialType.FAL_AI_API_KEY]: mockApiKey, + }, + }); + + const result = await bubble.performAction(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network timeout'); + }); + }); +}); diff --git a/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts new file mode 100644 index 00000000..b599e55b --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts @@ -0,0 +1,1328 @@ +import { z } from 'zod'; +import { ServiceBubble } from '../../types/service-bubble-class.js'; +import type { BubbleContext } from '../../types/bubble.js'; +import { CredentialType, type BubbleName } from '@bubblelab/shared-schemas'; + +// Base URLs for fal.ai APIs +const FAL_AI_BASE_URL = 'https://queue.fal.run'; +const FAL_AI_MODELS_API_URL = 'https://api.fal.ai/v1'; + +// Define the parameters schema for the fal.ai bubble +export const FalAiParamsSchema = z.discriminatedUnion('operation', [ + z.object({ + operation: z + .literal('text_to_image') + .describe('Generate an image from a text prompt'), + model: z + .string() + .min(1, 'Model is required') + .describe( + 'Fal AI model ID (e.g., "fal-ai/flux/dev", "fal-ai/stable-diffusion-v1-5")' + ), + prompt: z + .string() + .min(1, 'Prompt is required') + .describe('Text prompt describing the image to generate'), + imageSize: z + .enum([ + 'square_hd', + 'square', + 'portrait_4_3', + 'portrait_16_9', + 'landscape_4_3', + 'landscape_16_9', + ]) + .optional() + .default('square_hd') + .describe('Image size/aspect ratio'), + numImages: z + .number() + .int() + .min(1) + .max(4) + .optional() + .default(1) + .describe('Number of images to generate (1-4)'), + seed: z + .number() + .int() + .optional() + .describe('Random seed for reproducible results'), + numInferenceSteps: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Number of inference steps (higher = better quality, slower)'), + guidanceScale: z + .number() + .min(0) + .max(20) + .optional() + .describe('Guidance scale (higher = more adherence to prompt)'), + enableSafetyChecker: z + .boolean() + .optional() + .default(true) + .describe('Enable safety checker to filter inappropriate content'), + waitForResult: z + .boolean() + .optional() + .default(true) + .describe( + 'Whether to wait for the result (true) or return request_id immediately (false)' + ), + maxWaitTime: z + .number() + .int() + .min(1000) + .max(300000) + .optional() + .default(300000) + .describe( + 'Maximum time to wait for result in milliseconds (default: 5 minutes)' + ), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), + z.object({ + operation: z + .literal('image_to_image') + .describe('Transform an image based on a text prompt'), + model: z + .string() + .min(1, 'Model is required') + .describe('Fal AI model ID for image-to-image (e.g., "fal-ai/flux/dev")'), + prompt: z + .string() + .min(1, 'Prompt is required') + .describe('Text prompt describing the transformation'), + imageUrl: z + .string() + .url('Must be a valid URL') + .describe('URL of the source image to transform'), + strength: z + .number() + .min(0) + .max(1) + .optional() + .default(0.8) + .describe( + 'Transformation strength (0.0 = no change, 1.0 = full transformation)' + ), + numImages: z + .number() + .int() + .min(1) + .max(4) + .optional() + .default(1) + .describe('Number of images to generate (1-4)'), + seed: z + .number() + .int() + .optional() + .describe('Random seed for reproducible results'), + waitForResult: z + .boolean() + .optional() + .default(true) + .describe( + 'Whether to wait for the result (true) or return request_id immediately (false)' + ), + maxWaitTime: z + .number() + .int() + .min(1000) + .max(300000) + .optional() + .default(300000) + .describe( + 'Maximum time to wait for result in milliseconds (default: 5 minutes)' + ), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), + z.object({ + operation: z + .literal('get_status') + .describe('Check the status of an async job'), + requestId: z + .string() + .min(1, 'Request ID is required') + .describe('The request ID returned from a previous operation'), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), + z.object({ + operation: z + .literal('get_result') + .describe('Retrieve the result of a completed job'), + requestId: z + .string() + .min(1, 'Request ID is required') + .describe('The request ID of the completed job'), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), + z.object({ + operation: z + .literal('list_models') + .describe('List all available models from fal.ai'), + limit: z + .number() + .int() + .min(1) + .optional() + .default(50) + .describe('Maximum number of models to return (default: 50)'), + cursor: z + .string() + .optional() + .describe('Pagination cursor from previous response'), + expand: z + .array(z.literal('openapi-3.0')) + .optional() + .describe( + 'Fields to expand - use ["openapi-3.0"] to include full OpenAPI schema' + ), + category: z + .string() + .optional() + .describe( + 'Filter by category (e.g., "text-to-image", "image-to-video", "training")' + ), + status: z + .enum(['active', 'deprecated']) + .optional() + .describe('Filter models by status (active or deprecated)'), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), + z.object({ + operation: z + .literal('search_models') + .describe('Search for models by query string'), + query: z + .string() + .min(1, 'Search query is required') + .describe( + 'Free-text search query to filter models by name, description, or category' + ), + limit: z + .number() + .int() + .min(1) + .optional() + .default(50) + .describe('Maximum number of models to return (default: 50)'), + cursor: z + .string() + .optional() + .describe('Pagination cursor from previous response'), + expand: z + .array(z.literal('openapi-3.0')) + .optional() + .describe( + 'Fields to expand - use ["openapi-3.0"] to include full OpenAPI schema' + ), + category: z + .string() + .optional() + .describe( + 'Filter by category (e.g., "text-to-image", "image-to-video", "training")' + ), + status: z + .enum(['active', 'deprecated']) + .optional() + .describe('Filter models by status (active or deprecated)'), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), + z.object({ + operation: z + .literal('get_model') + .describe('Get specific model(s) by endpoint ID'), + endpointId: z + .union([z.string().min(1), z.array(z.string().min(1))]) + .describe( + 'Model endpoint ID(s) to retrieve (e.g., "fal-ai/flux/dev" or ["fal-ai/flux/dev", "fal-ai/flux-pro"])' + ), + expand: z + .array(z.literal('openapi-3.0')) + .optional() + .describe( + 'Fields to expand - use ["openapi-3.0"] to include full OpenAPI schema' + ), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), +]); + +export type FalAiParamsInput = z.input; +export type FalAiParamsParsed = z.output; + +// Define model metadata schemas for model discovery operations +const ModelGroupSchema = z + .object({ + key: z.string().describe('Group key identifier'), + label: z.string().describe('Human-readable group label'), + }) + .describe('Model group information'); + +const ModelMetadataSchema = z + .object({ + display_name: z.string().optional().describe('Human-readable model name'), + category: z + .string() + .optional() + .describe( + 'Model category (e.g., text-to-image, image-to-video, training)' + ), + description: z + .string() + .optional() + .describe('Brief description of the model'), + status: z + .enum(['active', 'deprecated']) + .optional() + .describe('Model status'), + tags: z + .array(z.string()) + .optional() + .describe('Tags like new, beta, pro, turbo'), + updated_at: z + .string() + .optional() + .describe('ISO8601 timestamp of last update'), + is_favorited: z + .boolean() + .nullable() + .optional() + .describe('Whether favorited by authenticated user'), + thumbnail_url: z.string().optional().describe('Main thumbnail image URL'), + model_url: z.string().optional().describe('Full model endpoint URL'), + date: z.string().optional().describe('ISO8601 timestamp of creation'), + highlighted: z + .boolean() + .optional() + .describe('Whether model is highlighted'), + pinned: z.boolean().optional().describe('Whether model is pinned'), + thumbnail_animated_url: z + .string() + .optional() + .describe('Animated thumbnail URL'), + github_url: z.string().optional().describe('License or GitHub URL'), + license_type: z + .enum(['commercial', 'research', 'private']) + .optional() + .describe('License type'), + group: ModelGroupSchema.optional().describe('Model group information'), + kind: z.enum(['inference', 'training']).optional().describe('Model kind'), + training_endpoint_ids: z + .array(z.string()) + .optional() + .describe('Related training endpoint IDs'), + inference_endpoint_ids: z + .array(z.string()) + .optional() + .describe('Related inference endpoint IDs'), + stream_url: z.string().optional().describe('Streaming endpoint URL'), + duration_estimate: z + .number() + .optional() + .describe('Estimated duration in minutes'), + }) + .passthrough() // Allow additional fields + .describe('Model metadata'); + +const FalAiModelSchema = z + .object({ + endpoint_id: z + .string() + .describe('Stable identifier used to call the model'), + metadata: ModelMetadataSchema.optional().describe( + 'Model metadata (may be absent for endpoints without registry entries)' + ), + openapi: z + .union([ + z + .object({ + openapi: z.string().describe('OpenAPI version (e.g., 3.0.4)'), + }) + .passthrough(), // Allow full OpenAPI schema + z.object({ + error: z.string().describe('Error message if schema unavailable'), + }), + ]) + .optional() + .describe('OpenAPI 3.0 specification (when expand=openapi-3.0)'), + }) + .passthrough() // Allow additional fields + .describe('fal.ai model information'); + +// Define result schemas +export const FalAiResultSchema = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('text_to_image'), + requestId: z + .string() + .optional() + .describe( + 'Request ID for async operations (when waitForResult is false)' + ), + imageUrl: z + .string() + .url() + .optional() + .describe('URL of the generated image'), + imageUrls: z + .array(z.string().url()) + .optional() + .describe('URLs of multiple generated images (when numImages > 1)'), + status: z + .string() + .optional() + .describe( + 'Status of the request (IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED)' + ), + success: z.boolean().describe('Whether the operation was successful'), + error: z.string().describe('Error message if the operation failed'), + }), + z.object({ + operation: z.literal('image_to_image'), + requestId: z + .string() + .optional() + .describe( + 'Request ID for async operations (when waitForResult is false)' + ), + imageUrl: z + .string() + .url() + .optional() + .describe('URL of the transformed image'), + imageUrls: z + .array(z.string().url()) + .optional() + .describe('URLs of multiple transformed images (when numImages > 1)'), + status: z + .string() + .optional() + .describe( + 'Status of the request (IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED)' + ), + success: z.boolean().describe('Whether the operation was successful'), + error: z.string().describe('Error message if the operation failed'), + }), + z.object({ + operation: z.literal('get_status'), + status: z + .string() + .describe( + 'Current status of the request (IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED)' + ), + success: z.boolean().describe('Whether the status check was successful'), + error: z.string().describe('Error message if the status check failed'), + }), + z.object({ + operation: z.literal('get_result'), + imageUrl: z + .string() + .url() + .optional() + .describe('URL of the generated/transformed image'), + imageUrls: z + .array(z.string().url()) + .optional() + .describe('URLs of multiple images if multiple were generated'), + status: z.string().optional().describe('Final status of the request'), + success: z + .boolean() + .describe('Whether the result retrieval was successful'), + error: z.string().describe('Error message if the result retrieval failed'), + }), + z.object({ + operation: z.literal('list_models'), + models: z + .array(FalAiModelSchema) + .optional() + .describe('Array of available models'), + has_more: z + .boolean() + .optional() + .describe('Whether more results are available'), + next_cursor: z + .string() + .optional() + .describe('Cursor for next page of results'), + success: z.boolean().describe('Whether the operation was successful'), + error: z.string().describe('Error message if the operation failed'), + }), + z.object({ + operation: z.literal('search_models'), + models: z + .array(FalAiModelSchema) + .optional() + .describe('Array of matching models'), + has_more: z + .boolean() + .optional() + .describe('Whether more results are available'), + next_cursor: z + .string() + .optional() + .describe('Cursor for next page of results'), + success: z.boolean().describe('Whether the operation was successful'), + error: z.string().describe('Error message if the operation failed'), + }), + z.object({ + operation: z.literal('get_model'), + models: z + .array(FalAiModelSchema) + .optional() + .describe('Array of requested models'), + success: z.boolean().describe('Whether the operation was successful'), + error: z.string().describe('Error message if the operation failed'), + }), +]); + +export type FalAiResult = z.output; + +export class FalAiBubble extends ServiceBubble { + static readonly type = 'service' as const; + static readonly service = 'fal-ai'; + static readonly authType = 'apikey' as const; + static readonly bubbleName: BubbleName = 'fal-ai'; + static readonly schema = FalAiParamsSchema; + static readonly resultSchema = FalAiResultSchema; + static readonly shortDescription = + 'fal.ai integration for media generation and model discovery'; + static readonly longDescription = ` + Integrate with Fal AI's media generation and model discovery APIs. + + Media Generation Features: + - Text-to-image generation with various models (Flux, Stable Diffusion, etc.) + - Image-to-image transformation + - Configurable image sizes and quality settings + - Async job status checking and result retrieval + - Automatic polling for long-running operations + + Model Discovery Features: + - List all available models with pagination + - Search models by query string (e.g., "veo3" finds Google Veo 3) + - Get specific models by endpoint ID + - Retrieve OpenAPI schemas for models + - Filter by category (text-to-image, image-to-video, etc.) and status + + Use cases: + - Generate images from text descriptions + - Transform existing images with AI + - Create multiple variations of images + - Discover available models for Pearl and other AI agents + - Dynamically retrieve model metadata and schemas + - Build model selection interfaces + + Supported Models: + - fal-ai/flux/dev - Fast, high-quality image generation + - fal-ai/stable-diffusion-v1-5 - Classic Stable Diffusion + - fal-ai/flux/schnell - Ultra-fast generation + - And many more (use list_models or search_models to discover) + `; + static readonly alias = 'falai'; + + constructor(params: FalAiParamsInput, context?: BubbleContext) { + super(params, context); + } + + protected chooseCredential(): string | undefined { + const credentials = this.params.credentials; + if (!credentials || typeof credentials !== 'object') { + return undefined; + } + return credentials[CredentialType.FAL_AI_API_KEY]; + } + public async testCredential(): Promise { + const apiKey = this.chooseCredential(); + if (!apiKey) return false; + + // Test by checking if we can make an authenticated request + // We'll use a simple status check with a dummy request ID + // The API will return 404 for invalid request ID, but 401 for invalid key + try { + const response = await fetch( + `${FAL_AI_BASE_URL}/test-request-id/status`, + { + method: 'GET', + headers: { + Authorization: `Key ${apiKey}`, + 'Content-Type': 'application/json', + }, + } + ); + + // 401 = invalid key, 404 = valid key but invalid request ID (which is fine) + return response.status !== 401; + } catch { + return false; + } + } + + protected async performAction(context?: BubbleContext): Promise { + void context; + const params = this.params; + + switch (params.operation) { + case 'text_to_image': + return this.textToImage(params); + case 'image_to_image': + return this.imageToImage(params); + case 'get_status': + return this.getStatus(params); + case 'get_result': + return this.getResult(params); + case 'list_models': + return this.listModels(params); + case 'search_models': + return this.searchModels(params); + case 'get_model': + return this.getModel(params); + default: + throw new Error(`Unknown operation: ${(params as any).operation}`); + } + } + + /** + * Poll for job status until completion or timeout + */ + private async pollForResult( + requestId: string, + maxWaitTime: number, + apiKey: string + ): Promise<{ status: string; result?: unknown }> { + const startTime = Date.now(); + let pollInterval = 1000; // Start with 1 second + const maxInterval = 10000; // Cap at 10 seconds + + while (Date.now() - startTime < maxWaitTime) { + // Direct API call to avoid schema validation issues + try { + const statusResponse = await this.makeApiRequest( + `/${requestId}/status`, + 'GET', + undefined, + apiKey + ); + + if (!statusResponse.ok) { + const errorText = await statusResponse.text(); + return { + status: 'FAILED', + result: { + error: `Failed to get status: ${statusResponse.status} ${statusResponse.statusText} - ${errorText}`, + }, + }; + } + + const statusData = (await statusResponse.json()) as { status: string }; + const status = statusData.status?.toUpperCase(); + + if (status === 'COMPLETED') { + // Fetch the result directly + const resultResponse = await this.makeApiRequest( + `/${requestId}`, + 'GET', + undefined, + apiKey + ); + + if (!resultResponse.ok) { + const errorText = await resultResponse.text(); + return { + status: 'FAILED', + result: { + error: `Failed to get result: ${resultResponse.status} ${resultResponse.statusText} - ${errorText}`, + }, + }; + } + + const resultData = (await resultResponse.json()) as { + images?: Array<{ url: string }>; + image?: { url: string }; + status?: string; + }; + + // Handle different response formats + let imageUrl: string | undefined; + let imageUrls: string[] | undefined; + + if ( + resultData.images && + Array.isArray(resultData.images) && + resultData.images.length > 0 + ) { + imageUrls = resultData.images.map((img) => img.url); + imageUrl = imageUrls[0]; // First image as primary + } else if (resultData.image?.url) { + imageUrl = resultData.image.url; + } + + return { + status: 'COMPLETED', + result: { + operation: 'get_result', + imageUrl, + imageUrls, + status: resultData.status, + success: true, + error: '', + }, + }; + } + + if (status === 'FAILED') { + return { status: 'FAILED', result: { error: 'Job failed' } }; + } + + // Still in progress, wait before next poll + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + pollInterval = Math.min(pollInterval * 1.5, maxInterval); // Exponential backoff + } catch (error) { + return { + status: 'FAILED', + result: { + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }, + }; + } + } + + return { + status: 'TIMEOUT', + result: { error: 'Polling timeout exceeded' }, + }; + } + + /** + * Make API request to fal.ai + */ + private async makeApiRequest( + endpoint: string, + method: 'GET' | 'POST', + body?: unknown, + apiKey?: string + ): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (apiKey) { + headers['Authorization'] = `Key ${apiKey}`; + } + + return fetch(`${FAL_AI_BASE_URL}${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + } + + private async textToImage( + params: Extract + ): Promise> { + const { + model, + prompt, + imageSize, + numImages, + seed, + numInferenceSteps, + guidanceScale, + enableSafetyChecker, + waitForResult, + maxWaitTime, + } = params; + + const apiKey = this.chooseCredential(); + + if (!apiKey) { + return { + operation: 'text_to_image', + success: false, + error: 'fal.ai API key is required', + }; + } + + try { + // Build request body + const requestBody: Record = { + prompt, + image_size: imageSize, + num_images: numImages, + }; + + if (seed !== undefined) { + requestBody.seed = seed; + } + if (numInferenceSteps !== undefined) { + requestBody.num_inference_steps = numInferenceSteps; + } + if (guidanceScale !== undefined) { + requestBody.guidance_scale = guidanceScale; + } + if (enableSafetyChecker !== undefined) { + requestBody.enable_safety_checker = enableSafetyChecker; + } + + // Submit the request + const response = await this.makeApiRequest( + `/fal-ai/${model}`, + 'POST', + requestBody, + apiKey + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + operation: 'text_to_image', + success: false, + error: `API request failed: ${response.status} ${response.statusText} - ${errorText}`, + }; + } + + const data = (await response.json()) as { + request_id: string; + status?: string; + }; + + const requestId = data.request_id; + + // If not waiting for result, return immediately + if (!waitForResult) { + return { + operation: 'text_to_image', + requestId, + success: true, + error: '', + }; + } + + // Poll for result + const pollResult = await this.pollForResult( + requestId, + maxWaitTime ?? 300000, + apiKey + ); + + if (pollResult.status === 'COMPLETED' && pollResult.result) { + const result = pollResult.result as Extract< + FalAiResult, + { operation: 'get_result' } + >; + return { + operation: 'text_to_image', + imageUrl: result.imageUrl, + imageUrls: result.imageUrls, + status: 'COMPLETED', + success: true, + error: '', + }; + } + + return { + operation: 'text_to_image', + requestId, + status: pollResult.status, + success: false, + error: + (pollResult.result as { error?: string })?.error || + 'Image generation failed or timed out', + }; + } catch (error) { + return { + operation: 'text_to_image', + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + private async imageToImage( + params: Extract + ): Promise> { + const { + model, + prompt, + imageUrl, + strength, + numImages, + seed, + waitForResult, + maxWaitTime, + } = params; + + const apiKey = this.chooseCredential(); + + if (!apiKey) { + return { + operation: 'image_to_image', + success: false, + error: 'fal.ai API key is required', + }; + } + + try { + // Build request body + const requestBody: Record = { + prompt, + image_url: imageUrl, + strength, + num_images: numImages, + }; + + if (seed !== undefined) { + requestBody.seed = seed; + } + + // Submit the request + const response = await this.makeApiRequest( + `/fal-ai/${model}`, + 'POST', + requestBody, + apiKey + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + operation: 'image_to_image', + success: false, + error: `API request failed: ${response.status} ${response.statusText} - ${errorText}`, + }; + } + + const data = (await response.json()) as { + request_id: string; + status?: string; + }; + + const requestId = data.request_id; + + // If not waiting for result, return immediately + if (!waitForResult) { + return { + operation: 'image_to_image', + requestId, + success: true, + error: '', + }; + } + + // Poll for result + const pollResult = await this.pollForResult( + requestId, + maxWaitTime ?? 300000, + apiKey + ); + + if (pollResult.status === 'COMPLETED' && pollResult.result) { + const result = pollResult.result as Extract< + FalAiResult, + { operation: 'get_result' } + >; + return { + operation: 'image_to_image', + imageUrl: result.imageUrl, + imageUrls: result.imageUrls, + status: 'COMPLETED', + success: true, + error: '', + }; + } + + return { + operation: 'image_to_image', + requestId, + status: pollResult.status, + success: false, + error: + (pollResult.result as { error?: string })?.error || + 'Image transformation failed or timed out', + }; + } catch (error) { + return { + operation: 'image_to_image', + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + private async getStatus( + params: Extract + ): Promise> { + const { requestId } = params; + const apiKey = this.chooseCredential(); + + if (!apiKey) { + return { + operation: 'get_status', + status: 'FAILED', + success: false, + error: 'fal.ai API key is required', + }; + } + + try { + const response = await this.makeApiRequest( + `/${requestId}/status`, + 'GET', + undefined, + apiKey + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + operation: 'get_status', + status: 'FAILED', + success: false, + error: `Failed to get status: ${response.status} ${response.statusText} - ${errorText}`, + }; + } + + const data = (await response.json()) as { status: string }; + return { + operation: 'get_status', + status: data.status, + success: true, + error: '', + }; + } catch (error) { + return { + operation: 'get_status', + status: 'FAILED', + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + private async getResult( + params: Extract + ): Promise> { + const { requestId } = params; + const apiKey = this.chooseCredential(); + + if (!apiKey) { + return { + operation: 'get_result', + success: false, + error: 'fal.ai API key is required', + }; + } + + try { + const response = await this.makeApiRequest( + `/${requestId}`, + 'GET', + undefined, + apiKey + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + operation: 'get_result', + success: false, + error: `Failed to get result: ${response.status} ${response.statusText} - ${errorText}`, + }; + } + + const data = (await response.json()) as { + images?: Array<{ url: string }>; + image?: { url: string }; + status?: string; + }; + + // Handle different response formats + let imageUrl: string | undefined; + let imageUrls: string[] | undefined; + + if (data.images && Array.isArray(data.images) && data.images.length > 0) { + imageUrls = data.images.map((img) => img.url); + imageUrl = imageUrls[0]; // First image as primary + } else if (data.image?.url) { + imageUrl = data.image.url; + } + + return { + operation: 'get_result', + imageUrl, + imageUrls, + status: data.status, + success: true, + error: '', + }; + } catch (error) { + return { + operation: 'get_result', + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * List all available models from fal.ai + */ + private async listModels( + params: Extract + ): Promise> { + const { limit, cursor, expand, category, status } = params; + const apiKey = this.chooseCredential(); + + try { + // Build query parameters + const queryParams = new URLSearchParams(); + if (limit !== undefined) { + queryParams.append('limit', limit.toString()); + } + if (cursor) { + queryParams.append('cursor', cursor); + } + if (expand && expand.length > 0) { + expand.forEach((e) => queryParams.append('expand', e)); + } + if (category) { + queryParams.append('category', category); + } + if (status) { + queryParams.append('status', status); + } + + const url = `${FAL_AI_MODELS_API_URL}/models${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (apiKey) { + headers['Authorization'] = `Key ${apiKey}`; + } + + const response = await fetch(url, { + method: 'GET', + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + operation: 'list_models', + success: false, + error: `Failed to list models: ${response.status} ${response.statusText} - ${errorText}`, + }; + } + + const data = (await response.json()) as { + models?: unknown[]; + has_more?: boolean; + next_cursor?: string; + }; + + return { + operation: 'list_models', + models: data.models as any[], // Will be validated by Zod schema + has_more: data.has_more, + next_cursor: data.next_cursor, + success: true, + error: '', + }; + } catch (error) { + return { + operation: 'list_models', + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Search for models by query string + */ + private async searchModels( + params: Extract + ): Promise> { + const { query, limit, cursor, expand, category, status } = params; + const apiKey = this.chooseCredential(); + + try { + // Build query parameters + const queryParams = new URLSearchParams(); + queryParams.append('q', query); + if (limit !== undefined) { + queryParams.append('limit', limit.toString()); + } + if (cursor) { + queryParams.append('cursor', cursor); + } + if (expand && expand.length > 0) { + expand.forEach((e) => queryParams.append('expand', e)); + } + if (category) { + queryParams.append('category', category); + } + if (status) { + queryParams.append('status', status); + } + + const url = `${FAL_AI_MODELS_API_URL}/models?${queryParams.toString()}`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (apiKey) { + headers['Authorization'] = `Key ${apiKey}`; + } + + const response = await fetch(url, { + method: 'GET', + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + operation: 'search_models', + success: false, + error: `Failed to search models: ${response.status} ${response.statusText} - ${errorText}`, + }; + } + + const data = (await response.json()) as { + models?: unknown[]; + has_more?: boolean; + next_cursor?: string; + }; + + return { + operation: 'search_models', + models: data.models as any[], // Will be validated by Zod schema + has_more: data.has_more, + next_cursor: data.next_cursor, + success: true, + error: '', + }; + } catch (error) { + return { + operation: 'search_models', + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Get specific model(s) by endpoint ID + */ + private async getModel( + params: Extract + ): Promise> { + const { endpointId, expand } = params; + const apiKey = this.chooseCredential(); + + try { + // Build query parameters + const queryParams = new URLSearchParams(); + + // Handle single or multiple endpoint IDs + const endpointIds = Array.isArray(endpointId) ? endpointId : [endpointId]; + endpointIds.forEach((id) => queryParams.append('endpoint_id', id)); + + if (expand && expand.length > 0) { + expand.forEach((e) => queryParams.append('expand', e)); + } + + const url = `${FAL_AI_MODELS_API_URL}/models?${queryParams.toString()}`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (apiKey) { + headers['Authorization'] = `Key ${apiKey}`; + } + + const response = await fetch(url, { + method: 'GET', + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + operation: 'get_model', + success: false, + error: `Failed to get model: ${response.status} ${response.statusText} - ${errorText}`, + }; + } + + const data = (await response.json()) as { + models?: unknown[]; + }; + + return { + operation: 'get_model', + models: data.models as any[], // Will be validated by Zod schema + success: true, + error: '', + }; + } catch (error) { + return { + operation: 'get_model', + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } +} diff --git a/packages/bubble-core/src/index.ts b/packages/bubble-core/src/index.ts index 81d698e8..c5d6e7f8 100644 --- a/packages/bubble-core/src/index.ts +++ b/packages/bubble-core/src/index.ts @@ -65,6 +65,8 @@ export type { AGIIncParamsInput } from './bubbles/service-bubble/agi-inc.js'; export { AirtableBubble } from './bubbles/service-bubble/airtable.js'; export type { AirtableParamsInput } from './bubbles/service-bubble/airtable.js'; export { NotionBubble } from './bubbles/service-bubble/notion/notion.js'; +export { FalAiBubble } from './bubbles/service-bubble/fal-ai.js'; +export type { FalAiParamsInput } from './bubbles/service-bubble/fal-ai.js'; export type { FirecrawlParamsInput } from './bubbles/service-bubble/firecrawl.js'; export { FirecrawlBubble } from './bubbles/service-bubble/firecrawl.js'; export { InsForgeDbBubble } from './bubbles/service-bubble/insforge-db.js'; diff --git a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts index 3c3a2c66..54a34d57 100644 --- a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts +++ b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts @@ -44,6 +44,7 @@ export const CREDENTIAL_CONFIGURATION_MAP: Record< [CredentialType.GITHUB_TOKEN]: {}, [CredentialType.AIRTABLE_CRED]: {}, [CredentialType.NOTION_OAUTH_TOKEN]: {}, + [CredentialType.FAL_AI_API_KEY]: {}, [CredentialType.INSFORGE_BASE_URL]: {}, [CredentialType.INSFORGE_API_KEY]: {}, }; diff --git a/packages/bubble-shared-schemas/src/credential-schema.ts b/packages/bubble-shared-schemas/src/credential-schema.ts index 8fe1fb9b..e09ab01a 100644 --- a/packages/bubble-shared-schemas/src/credential-schema.ts +++ b/packages/bubble-shared-schemas/src/credential-schema.ts @@ -29,6 +29,7 @@ export const CREDENTIAL_ENV_MAP: Record = { [CredentialType.AGI_API_KEY]: 'AGI_API_KEY', [CredentialType.AIRTABLE_CRED]: 'AIRTABLE_API_KEY', [CredentialType.NOTION_OAUTH_TOKEN]: '', + [CredentialType.FAL_AI_API_KEY]: 'FAL_AI_API_KEY', [CredentialType.INSFORGE_BASE_URL]: 'INSFORGE_BASE_URL', [CredentialType.INSFORGE_API_KEY]: 'INSFORGE_API_KEY', }; @@ -364,6 +365,7 @@ export const BUBBLE_CREDENTIAL_OPTIONS: Record = { 'agi-inc': [CredentialType.AGI_API_KEY], airtable: [CredentialType.AIRTABLE_CRED], notion: [CredentialType.NOTION_OAUTH_TOKEN], + 'fal-ai': [CredentialType.FAL_AI_API_KEY], firecrawl: [CredentialType.FIRECRAWL_API_KEY], 'insforge-db': [ CredentialType.INSFORGE_BASE_URL, diff --git a/packages/bubble-shared-schemas/src/types.ts b/packages/bubble-shared-schemas/src/types.ts index 59585072..df75cff3 100644 --- a/packages/bubble-shared-schemas/src/types.ts +++ b/packages/bubble-shared-schemas/src/types.ts @@ -42,6 +42,8 @@ export enum CredentialType { // Database/Storage Credentials AIRTABLE_CRED = 'AIRTABLE_CRED', + // Media Generation Credentials + FAL_AI_API_KEY = 'FAL_AI_API_KEY', // InsForge Credentials INSFORGE_BASE_URL = 'INSFORGE_BASE_URL', INSFORGE_API_KEY = 'INSFORGE_API_KEY', @@ -93,5 +95,6 @@ export type BubbleName = | 'telegram' | 'airtable' | 'notion' + | 'fal-ai' | 'firecrawl' | 'insforge-db';