From f237a1cc64182f654216cb46a75d67cf7e92c768 Mon Sep 17 00:00:00 2001 From: Muhammad Rubeel Saleem Date: Wed, 10 Dec 2025 21:49:15 -0500 Subject: [PATCH 1/7] feat(fal-ai): add fal.ai integration for media generation - Implement FalAiBubble with 4 operations (text_to_image, image_to_image, get_status, get_result) - Add async polling with exponential backoff for long-running jobs - Configure credential management (FAL_AI_API_KEY) in backend and frontend - Add logo integration and UI configuration - Include 28 unit tests covering all operations and error cases - Add type safety with Zod schemas and TypeScript types Closes #175 --- .../public/integrations/fal-ai.png | Bin 0 -> 3244 bytes apps/bubble-studio/src/lib/integrations.ts | 8 +- .../src/pages/CredentialsPage.tsx | 9 + .../src/services/credential-validator.ts | 5 + packages/bubble-core/src/bubble-factory.ts | 4 + .../src/bubbles/service-bubble/fal-ai.test.ts | 837 +++++++++++++++++ .../src/bubbles/service-bubble/fal-ai.ts | 849 ++++++++++++++++++ packages/bubble-core/src/index.ts | 2 + .../src/bubble-definition-schema.ts | 1 + .../src/credential-schema.ts | 2 + packages/bubble-shared-schemas/src/types.ts | 6 +- 11 files changed, 1721 insertions(+), 2 deletions(-) create mode 100644 apps/bubble-studio/public/integrations/fal-ai.png create mode 100644 packages/bubble-core/src/bubbles/service-bubble/fal-ai.test.ts create mode 100644 packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts 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 0000000000000000000000000000000000000000..f6db2f7bfb007af080a8ed90d16879eb215e4887 GIT binary patch literal 3244 zcmcImX*iS(8-0w)QYcd?Ya&BJGbH<#b+ToNVN@6lF_y7wyrvREW1j{Mri6Owl`VV9 z%gmHzlr_ngvF{{1pP%2K@8@@}`?}73&V8NV_er>AWy*hC@pXl4Px zxxk!y8||Y5$lcu32sn&S>G*i0xI++@#@w@fCqN*rfPp?K060;FFfy=@_^~(~nJPpV zX09$TfgS{w))Vx>f`ZMU*e8U1T!f?n$&5#^;7yhhB*nYYmg_w+PX26$ltgRRjlx8* zs*R-l@x*+t$qJr16QNW?&NsD>+TXV=QT4#X=#kmH@S@T1kpZ(F0uhs2@rE;sB9*evj6&-q=XQCV^k{5w?u@@eBT z8niDZ=NZ?dzKeXTlJw4-{1d8wWhtQ`r)lqBH}aI%+=oVfgSC%7Iivg04Nf0+&|79sz3WoIhN=ob{> zK2J1^D$%=d>S$&tPDjY-Yl}Y8EGG`K2X+@P)Dcqb6oU8*8y%LzF_n1<#a?vFVE+w2 zJDQMzV)RSXx~n$DD*7%;9N2AsEgjD1f{5*Hg>|`xxF;my3ttLEGB(V@D~);kaGq9| zwvpretg0bQL*@LbV)xn~wW#T9BVe;d;2G?Zokq5;)gPx45{DRZA!V6<^SSJu`jp$` z^0dCjvw_!a^hRJ|)jz+;^%wq>QYJQqygb3|$&p&q(3jc$Zg`A3`00K>?z1L`iD-X> zruQb*9x{eMuri-;zGu_6W43-ct?g0XFU1h4_kBOUD*d?E`CIdj4_!jnH71uab1r+f z@~_izD#>u6O}*QVZJ?=2+0(qKiIxssD;Sf5y1KVjJ8a%K94qNX{EbK8!wsMI#wRB^ zDb=)ijf`5Leg+w`#cMnTmS0w5oNn?mVlg}9_feU!803OXb;G}xu<`PevTuAjYqdczZ zqPb4)S%+w{doIw0wD-1M$AMqrUd;_Vegs9!SR3KOH1f`MhYJN}0P8t)RLkg%%pmoA zEX<1DeoIyNKm5_X21w* zJkx#BBo8p|NZAv8kkJKJgNG?U($+ zdik%DrO@bB9;Tp$8vd_T*;M8Vx;QDu=Gd!f+sW(b^<>9?r`D@igRdr|4O0j2GI>{w zV?A%fn7>FHVqI&I-Gvi;sL|<2o3|C}bQ(1`lo0Ji1_;*F1DggsCA6?+yhz3A)pzp+ z7=FoVFdHakikj$zyGr$nOrI}lZAhoocb8$UH`g!Kt0NV~m_HNM;hk=5wc6p{;=$g1 zK7fd2Zm8?x{Q zp5{2Pl>dtEoKhyodAlj{r!X@zYSWJA_PMjyJsZy7?KLar)}+Q)%J2Z)yKXcwTU?-q32=i=|5Dt8l+%?Ek~|;_8bG zi{Kj~q3$?LSGwyU>?Q2BK1&xEzl?Veb$iTQkPMy`vtf@zT=VG7qpZlhA>FKPFtPNP zZ72HouW>H5gmdNBo@@r;OZ2XYXagf1br#8Gf2Gx1kzSgkE%dMwg#6?7+k?{}wb|mZ~`5BLvBl+11V)`YaDnFDxPVp-8EV~+IUrN4>?ZhZ>`@<)#hg7Eje4u|c^%eK*A_ae zy-%p`sET{JsI4sU&dxt`Fk9B=ya*wiuHbk?E%^dwzMq?c^ph^~jq^p}7Sb-e3X~^> zN8}#!+eCgO+9hSa3Fv~n-u`2-lNkQUfiw72_;ERQJCR|MK>U9|`jB%~S0~rfYT5dz z(#wPxBSSpgLOeCmIL{*jlwr!Mig0B`INTnlta&u5>Q`VeO&H9zwCAYs|5pGT;NjyH X`M(2#>%MtM0RUlaWmI~@_0fL-rMvXX literal 0 HcmV?d00001 diff --git a/apps/bubble-studio/src/lib/integrations.ts b/apps/bubble-studio/src/lib/integrations.ts index 220561ff..025e1165 100644 --- a/apps/bubble-studio/src/lib/integrations.ts +++ b/apps/bubble-studio/src/lib/integrations.ts @@ -24,7 +24,8 @@ 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', 'Follow Up Boss': '/integrations/FUB.png', 'AGI Inc': '/integrations/agi-inc.svg', Telegram: '/integrations/telegram.svg', @@ -67,6 +68,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'] }, @@ -122,6 +124,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', @@ -222,6 +227,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 9e71548b..b0866421 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: {}, + }, } as const satisfies Record; // Helper to extract error message from API error @@ -282,6 +290,7 @@ const getServiceNameForCredentialType = ( [CredentialType.ELEVENLABS_API_KEY]: 'ElevenLabs', [CredentialType.AIRTABLE_CRED]: 'Airtable', [CredentialType.NOTION_OAUTH_TOKEN]: 'Notion', + [CredentialType.FAL_AI_API_KEY]: 'FalAi', }; return typeToServiceMap[credentialType] || credentialType; 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 3c171000..3489d1c2 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', ]; } @@ -274,6 +275,7 @@ export class BubbleFactory { const { AirtableBubble } = await import( './bubbles/service-bubble/airtable.js' ); + const { FalAiBubble } = await import('./bubbles/service-bubble/fal-ai.js'); // Create the default factory instance this.register('hello-world', HelloWorldBubble as BubbleClassWithMetadata); @@ -375,6 +377,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); // After all default bubbles are registered, auto-populate bubbleDependencies if (!BubbleFactory.dependenciesPopulated) { @@ -664,6 +667,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..e513e9c3 --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.test.ts @@ -0,0 +1,837 @@ +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: '', + }); + }); + }); +}); 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..c53a07d6 --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts @@ -0,0 +1,849 @@ +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 URL for fal.ai API +const FAL_AI_BASE_URL = 'https://queue.fal.run'; + +// 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)' + ), + }), +]); + +export type FalAiParamsInput = z.input; +export type FalAiParamsParsed = z.output; + +// 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'), + }), +]); + +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 (text-to-image, image-to-image)'; + static readonly longDescription = ` + Integrate with fal.ai's media generation APIs for creating and transforming images. + + 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 + + Use cases: + - Generate images from text descriptions + - Transform existing images with AI + - Create multiple variations of images + - Batch image generation workflows + + 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 (check fal.ai docs for full list) + `; + 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); + 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', + }; + } + } +} diff --git a/packages/bubble-core/src/index.ts b/packages/bubble-core/src/index.ts index a094fbc7..11ba8f58 100644 --- a/packages/bubble-core/src/index.ts +++ b/packages/bubble-core/src/index.ts @@ -64,6 +64,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 workflow bubbles export { DatabaseAnalyzerWorkflowBubble } from './bubbles/workflow-bubble/database-analyzer.workflow.js'; diff --git a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts index a24e54a1..90166697 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]: {}, }; // Fixed list of bubble names that need context injection diff --git a/packages/bubble-shared-schemas/src/credential-schema.ts b/packages/bubble-shared-schemas/src/credential-schema.ts index 2d0fe1c9..a3089a39 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', }; /** Used by bubblelab studio */ @@ -362,6 +363,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], }; // POST /credentials - Create credential schema diff --git a/packages/bubble-shared-schemas/src/types.ts b/packages/bubble-shared-schemas/src/types.ts index 2d0d0294..21d4808b 100644 --- a/packages/bubble-shared-schemas/src/types.ts +++ b/packages/bubble-shared-schemas/src/types.ts @@ -41,6 +41,9 @@ export enum CredentialType { // Database/Storage Credentials AIRTABLE_CRED = 'AIRTABLE_CRED', + + // Media Generation Credentials + FAL_AI_API_KEY = 'FAL_AI_API_KEY', } // Define all bubble names as a union type for type safety @@ -88,4 +91,5 @@ export type BubbleName = | 'agi-inc' | 'telegram' | 'airtable' - | 'notion'; + | 'notion' + | 'fal-ai'; From cb051d2c53318c37d5d9eec8c5d2fee1294223b5 Mon Sep 17 00:00:00 2001 From: Muhammad Rubeel Saleem Date: Wed, 10 Dec 2025 22:49:58 -0500 Subject: [PATCH 2/7] style: fix trailing whitespace in fal-ai.ts --- packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts index c53a07d6..441f16e5 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts @@ -282,20 +282,20 @@ export class FalAiBubble extends ServiceBubble { 'fal.ai integration for media generation (text-to-image, image-to-image)'; static readonly longDescription = ` Integrate with fal.ai's media generation APIs for creating and transforming images. - + 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 - + Use cases: - Generate images from text descriptions - Transform existing images with AI - Create multiple variations of images - Batch image generation workflows - + Supported Models: - fal-ai/flux/dev - Fast, high-quality image generation - fal-ai/stable-diffusion-v1-5 - Classic Stable Diffusion @@ -315,7 +315,6 @@ export class FalAiBubble extends ServiceBubble { } return credentials[CredentialType.FAL_AI_API_KEY]; } - public async testCredential(): Promise { const apiKey = this.chooseCredential(); if (!apiKey) return false; From 1b3eb4836493375620a0642da9817c67d103fc21 Mon Sep 17 00:00:00 2001 From: Muhammad Rubeel Saleem Date: Thu, 11 Dec 2025 00:33:51 -0500 Subject: [PATCH 3/7] fixing ui for falai --- apps/bubble-studio/src/lib/integrations.ts | 11 ++++++----- apps/bubble-studio/src/pages/CredentialsPage.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/bubble-studio/src/lib/integrations.ts b/apps/bubble-studio/src/lib/integrations.ts index 025e1165..bdd854c6 100644 --- a/apps/bubble-studio/src/lib/integrations.ts +++ b/apps/bubble-studio/src/lib/integrations.ts @@ -26,6 +26,7 @@ export const SERVICE_LOGOS: Readonly> = Object.freeze({ GitHub: '/integrations/github.svg', 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,7 +69,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: '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,9 +125,9 @@ const NAME_ALIASES: Readonly> = Object.freeze({ github: 'GitHub', elevenlabs: 'ElevenLabs', 'eleven-labs': 'ElevenLabs', - falai: 'fal.ai', - 'fal-ai': 'fal.ai', - fal: 'fal.ai', + falai: 'Fal AI', + 'fal-ai': 'Fal AI', + fal: 'Fal AI', followupboss: 'Follow Up Boss', fub: 'Follow Up Boss', 'follow-up-boss': 'Follow Up Boss', @@ -227,7 +228,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'], + [/\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 b0866421..3ecdbb0c 100644 --- a/apps/bubble-studio/src/pages/CredentialsPage.tsx +++ b/apps/bubble-studio/src/pages/CredentialsPage.tsx @@ -235,11 +235,11 @@ const CREDENTIAL_TYPE_CONFIG: Record = { credentialConfigurations: {}, }, [CredentialType.FAL_AI_API_KEY]: { - label: 'fal.ai', + label: 'Fal AI', description: - 'API key for fal.ai media generation services (text-to-image, image-to-image)', + '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', + namePlaceholder: 'My Fal AI Key', credentialConfigurations: {}, }, } as const satisfies Record; @@ -290,7 +290,7 @@ const getServiceNameForCredentialType = ( [CredentialType.ELEVENLABS_API_KEY]: 'ElevenLabs', [CredentialType.AIRTABLE_CRED]: 'Airtable', [CredentialType.NOTION_OAUTH_TOKEN]: 'Notion', - [CredentialType.FAL_AI_API_KEY]: 'FalAi', + [CredentialType.FAL_AI_API_KEY]: 'Fal AI', }; return typeToServiceMap[credentialType] || credentialType; From 6bb8585ba284e832115a0bb81d51d65bd3fada51 Mon Sep 17 00:00:00 2001 From: Muhammad Rubeel Saleem Date: Thu, 11 Dec 2025 01:39:41 -0500 Subject: [PATCH 4/7] frontend FalAi fix --- .../src/bubbles/service-bubble/fal-ai.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts index 441f16e5..ffc9dd14 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts @@ -16,7 +16,7 @@ export const FalAiParamsSchema = z.discriminatedUnion('operation', [ .string() .min(1, 'Model is required') .describe( - 'fal.ai model ID (e.g., "fal-ai/flux/dev", "fal-ai/stable-diffusion-v1-5")' + 'Fal AI model ID (e.g., "fal-ai/flux/dev", "fal-ai/stable-diffusion-v1-5")' ), prompt: z .string() @@ -96,7 +96,7 @@ export const FalAiParamsSchema = z.discriminatedUnion('operation', [ model: z .string() .min(1, 'Model is required') - .describe('fal.ai model ID for image-to-image (e.g., "fal-ai/flux/dev")'), + .describe('Fal AI model ID for image-to-image (e.g., "fal-ai/flux/dev")'), prompt: z .string() .min(1, 'Prompt is required') @@ -279,9 +279,9 @@ export class FalAiBubble extends ServiceBubble { static readonly schema = FalAiParamsSchema; static readonly resultSchema = FalAiResultSchema; static readonly shortDescription = - 'fal.ai integration for media generation (text-to-image, image-to-image)'; + 'Fal AI integration for media generation (text-to-image, image-to-image)'; static readonly longDescription = ` - Integrate with fal.ai's media generation APIs for creating and transforming images. + Integrate with Fal AI's media generation APIs for creating and transforming images. Features: - Text-to-image generation with various models (Flux, Stable Diffusion, etc.) @@ -300,7 +300,7 @@ export class FalAiBubble extends ServiceBubble { - 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 (check fal.ai docs for full list) + - And many more (check Fal AI docs for full list) `; static readonly alias = 'falai'; @@ -517,7 +517,7 @@ export class FalAiBubble extends ServiceBubble { return { operation: 'text_to_image', success: false, - error: 'fal.ai API key is required', + error: 'Fal AI API key is required', }; } @@ -637,7 +637,7 @@ export class FalAiBubble extends ServiceBubble { return { operation: 'image_to_image', success: false, - error: 'fal.ai API key is required', + error: 'Fal AI API key is required', }; } @@ -740,7 +740,7 @@ export class FalAiBubble extends ServiceBubble { operation: 'get_status', status: 'FAILED', success: false, - error: 'fal.ai API key is required', + error: 'Fal AI API key is required', }; } @@ -790,7 +790,7 @@ export class FalAiBubble extends ServiceBubble { return { operation: 'get_result', success: false, - error: 'fal.ai API key is required', + error: 'Fal AI API key is required', }; } From 236787e234542d53779c47a482c66b7a4039235f Mon Sep 17 00:00:00 2001 From: Muhammad Rubeel Saleem Date: Tue, 16 Dec 2025 02:20:30 -0500 Subject: [PATCH 5/7] feat: add model discovery operations to fal.ai bubble --- .../src/bubbles/service-bubble/fal-ai.test.ts | 509 ++++++++++++++++++ .../src/bubbles/service-bubble/fal-ai.ts | 500 ++++++++++++++++- 2 files changed, 999 insertions(+), 10 deletions(-) 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 index e513e9c3..046fb863 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/fal-ai.test.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.test.ts @@ -834,4 +834,513 @@ describe('FalAiBubble', () => { }); }); }); + + 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 index ffc9dd14..b599e55b 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/fal-ai.ts @@ -3,8 +3,9 @@ import { ServiceBubble } from '../../types/service-bubble-class.js'; import type { BubbleContext } from '../../types/bubble.js'; import { CredentialType, type BubbleName } from '@bubblelab/shared-schemas'; -// Base URL for fal.ai API +// 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', [ @@ -181,11 +182,214 @@ export const FalAiParamsSchema = z.discriminatedUnion('operation', [ '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({ @@ -267,6 +471,49 @@ export const FalAiResultSchema = z.discriminatedUnion('operation', [ .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; @@ -279,28 +526,37 @@ export class FalAiBubble extends ServiceBubble { static readonly schema = FalAiParamsSchema; static readonly resultSchema = FalAiResultSchema; static readonly shortDescription = - 'Fal AI integration for media generation (text-to-image, image-to-image)'; + 'fal.ai integration for media generation and model discovery'; static readonly longDescription = ` - Integrate with Fal AI's media generation APIs for creating and transforming images. + Integrate with Fal AI's media generation and model discovery APIs. - Features: + 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 - - Batch image generation workflows + - 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 (check Fal AI docs for full list) + - And many more (use list_models or search_models to discover) `; static readonly alias = 'falai'; @@ -354,6 +610,12 @@ export class FalAiBubble extends ServiceBubble { 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}`); } @@ -517,7 +779,7 @@ export class FalAiBubble extends ServiceBubble { return { operation: 'text_to_image', success: false, - error: 'Fal AI API key is required', + error: 'fal.ai API key is required', }; } @@ -637,7 +899,7 @@ export class FalAiBubble extends ServiceBubble { return { operation: 'image_to_image', success: false, - error: 'Fal AI API key is required', + error: 'fal.ai API key is required', }; } @@ -740,7 +1002,7 @@ export class FalAiBubble extends ServiceBubble { operation: 'get_status', status: 'FAILED', success: false, - error: 'Fal AI API key is required', + error: 'fal.ai API key is required', }; } @@ -790,7 +1052,7 @@ export class FalAiBubble extends ServiceBubble { return { operation: 'get_result', success: false, - error: 'Fal AI API key is required', + error: 'fal.ai API key is required', }; } @@ -845,4 +1107,222 @@ export class FalAiBubble extends ServiceBubble { }; } } + + /** + * 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', + }; + } + } } From 336579fc04debb1bd514ac5bd5ef33ac6f3ca4d4 Mon Sep 17 00:00:00 2001 From: Muhammad Rubeel Saleem Date: Tue, 16 Dec 2025 03:12:46 -0500 Subject: [PATCH 6/7] fix: resolved semicolon typo in shared-schemas --- packages/bubble-shared-schemas/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bubble-shared-schemas/src/types.ts b/packages/bubble-shared-schemas/src/types.ts index 8204b69b..df75cff3 100644 --- a/packages/bubble-shared-schemas/src/types.ts +++ b/packages/bubble-shared-schemas/src/types.ts @@ -95,6 +95,6 @@ export type BubbleName = | 'telegram' | 'airtable' | 'notion' - | 'fal-ai'; + | 'fal-ai' | 'firecrawl' | 'insforge-db'; From 527fb998cf87e52e4c87ede976e729b0115d276d Mon Sep 17 00:00:00 2001 From: Muhammad Rubeel Saleem Date: Tue, 16 Dec 2025 03:25:38 -0500 Subject: [PATCH 7/7] fix: semicolon placement in CredentialsPage.tsx --- apps/bubble-studio/src/pages/CredentialsPage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/bubble-studio/src/pages/CredentialsPage.tsx b/apps/bubble-studio/src/pages/CredentialsPage.tsx index 8e162e9d..55035ade 100644 --- a/apps/bubble-studio/src/pages/CredentialsPage.tsx +++ b/apps/bubble-studio/src/pages/CredentialsPage.tsx @@ -240,6 +240,8 @@ const CREDENTIAL_TYPE_CONFIG: Record = { '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: