From dacdbe3c756bb5c9946e57506d1704e9e5d4c245 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 20 May 2026 15:40:48 -0700 Subject: [PATCH 1/2] improvement(media-gen): retire vision block, add hosted key for fal ai for image/video gen --- .../content/docs/en/tools/azure_devops.mdx | 10 +- apps/docs/content/docs/en/tools/meta.json | 1 - apps/docs/content/docs/en/tools/vision.mdx | 60 ------- .../content/docs/en/triggers/azure_devops.mdx | 8 +- .../integrations/data/icon-mapping.ts | 2 - .../integrations/data/integrations.json | 26 +-- apps/sim/app/api/tools/image/route.ts | 15 ++ apps/sim/app/api/tools/video/route.ts | 25 ++- .../settings/components/byok/byok.tsx | 8 + .../[workspaceId]/w/[workflowId]/workflow.tsx | 16 +- .../components/search-modal/search-modal.tsx | 9 +- .../w/components/sidebar/sidebar.tsx | 8 +- apps/sim/blocks/blocks.test.ts | 59 +++++++ apps/sim/blocks/blocks/agent.ts | 1 + apps/sim/blocks/blocks/image_generator.ts | 20 ++- apps/sim/blocks/blocks/video_generator.ts | 27 +++- apps/sim/blocks/blocks/vision.ts | 2 +- apps/sim/blocks/types.ts | 2 + apps/sim/lib/api/contracts/byok-keys.ts | 1 + .../lib/api/contracts/tools/media/image.ts | 1 + .../lib/api/contracts/tools/media/video.ts | 1 + apps/sim/lib/tools/falai-pricing.ts | 140 ++++++++++++++++ apps/sim/stores/modals/search/store.test.ts | 151 ++++++++++++++++++ apps/sim/stores/modals/search/store.ts | 43 +++++ apps/sim/stores/modals/search/types.ts | 1 + apps/sim/tools/falai-hosting.test.ts | 61 +++++++ apps/sim/tools/image/generate.ts | 39 +++++ apps/sim/tools/image/types.ts | 10 ++ apps/sim/tools/index.test.ts | 124 ++++++++++++++ apps/sim/tools/index.ts | 8 + apps/sim/tools/types.ts | 3 + apps/sim/tools/video/falai.ts | 36 +++++ apps/sim/tools/video/types.ts | 10 ++ 33 files changed, 817 insertions(+), 111 deletions(-) delete mode 100644 apps/docs/content/docs/en/tools/vision.mdx create mode 100644 apps/sim/lib/tools/falai-pricing.ts create mode 100644 apps/sim/stores/modals/search/store.test.ts create mode 100644 apps/sim/tools/falai-hosting.test.ts diff --git a/apps/docs/content/docs/en/tools/azure_devops.mdx b/apps/docs/content/docs/en/tools/azure_devops.mdx index 38b3bfb69d8..e0add5348ae 100644 --- a/apps/docs/content/docs/en/tools/azure_devops.mdx +++ b/apps/docs/content/docs/en/tools/azure_devops.mdx @@ -280,7 +280,7 @@ Get the execution timeline for an Azure DevOps build — every stage, job, and t | ↳ `warningCount` | number | Number of warnings | | ↳ `startTime` | string | ISO 8601 start timestamp | | ↳ `finishTime` | string | ISO 8601 finish timestamp | -| ↳ `failedRecords` | array | Subset of records where result === "failed" — use logId to fetch logs | +| ↳ `failedRecords` | array | Subset of records where result is failed, partiallySucceeded, or succeededWithIssues — use logId to fetch logs | | ↳ `id` | string | Record GUID | | ↳ `name` | string | Step name | | ↳ `type` | string | Stage \| Phase \| Job \| Task | @@ -333,7 +333,8 @@ Execute a WIQL query to search for work items in Azure DevOps and return full fi | --------- | ---- | ----------- | | `content` | string | Human-readable summary of matching work items | | `metadata` | object | Work items metadata | -| ↳ `count` | number | Number of work items returned | +| ↳ `count` | number | Number of work items returned \(after hydration\) | +| ↳ `totalMatched` | number | Total number of work items matched by the WIQL query before hydration | | ↳ `workItems` | array | Array of work item details | | ↳ `id` | number | Work item ID | | ↳ `title` | string | Work item title | @@ -372,7 +373,7 @@ Fetch full details of a single work item by ID from Azure DevOps, including titl ### `azure_devops_get_work_items_batch` -Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. +Fetch full details for multiple work items by ID from Azure DevOps. Pass comma-separated IDs (e.g. #### Input @@ -380,7 +381,7 @@ Fetch full details for multiple work items by ID from Azure DevOps in a single c | --------- | ---- | -------- | ----------- | | `organization` | string | Yes | Azure DevOps organization name | | `project` | string | Yes | Azure DevOps project name | -| `ids` | string | Yes | Comma-separated work item IDs to fetch \(e.g. "123,456,789"\). Maximum 200 IDs. | +| `ids` | string | Yes | Comma-separated work item IDs to fetch \(e.g. "123,456,789"\). Lists longer than 200 IDs are chunked automatically. | #### Output @@ -389,6 +390,7 @@ Fetch full details for multiple work items by ID from Azure DevOps in a single c | `content` | string | Human-readable summary of the fetched work items | | `metadata` | object | Work items metadata | | ↳ `count` | number | Number of work items returned | +| ↳ `totalRequested` | number | Total number of IDs requested \(across all chunks\) | | ↳ `workItems` | array | Array of work item details | | ↳ `id` | number | Work item ID | | ↳ `title` | string | Work item title | diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 075f99a2954..f259653fdaa 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -195,7 +195,6 @@ "upstash", "vercel", "video_generator", - "vision", "wealthbox", "webflow", "whatsapp", diff --git a/apps/docs/content/docs/en/tools/vision.mdx b/apps/docs/content/docs/en/tools/vision.mdx deleted file mode 100644 index 1ab0cee507c..00000000000 --- a/apps/docs/content/docs/en/tools/vision.mdx +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: Vision -description: Analyze images with vision models ---- - -import { BlockInfoCard } from "@/components/ui/block-info-card" - - - -{/* MANUAL-CONTENT-START:intro */} -Vision is a tool that allows you to analyze images with vision models. - -With Vision, you can: - -- **Analyze images**: Analyze images with vision models -- **Extract text**: Extract text from images -- **Identify objects**: Identify objects in images -- **Describe images**: Describe images in detail -- **Generate images**: Generate images from text - -In Sim, the Vision integration enables your agents to analyze images with vision models as part of their workflows. This allows for powerful automation scenarios that require analyzing images with vision models. Your agents can analyze images with vision models, extract text from images, identify objects in images, describe images in detail, and generate images from text. This integration bridges the gap between your AI workflows and your image analysis needs, enabling more sophisticated and image-centric automations. By connecting Sim with Vision, you can create agents that stay current with the latest information, provide more accurate responses, and deliver more value to users - all without requiring manual intervention or custom code. -{/* MANUAL-CONTENT-END */} - - -## Usage Instructions - -Integrate Vision into the workflow. Can analyze images with vision models. - - - -## Tools - -### `vision_tool` - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | API key for the selected model provider | -| `imageUrl` | string | No | Publicly accessible image URL | -| `imageFile` | file | No | Image file to analyze | -| `model` | string | No | Vision model to use \(gpt-4o, claude-3-opus-20240229, etc\) | -| `prompt` | string | No | Custom prompt for image analysis | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `content` | string | The analyzed content and description of the image | -| `model` | string | The vision model that was used for analysis | -| `tokens` | number | Total tokens used for the analysis | -| `usage` | object | Detailed token usage breakdown | -| ↳ `input_tokens` | number | Tokens used for input processing | -| ↳ `output_tokens` | number | Tokens used for response generation | -| ↳ `total_tokens` | number | Total tokens consumed | - - diff --git a/apps/docs/content/docs/en/triggers/azure_devops.mdx b/apps/docs/content/docs/en/triggers/azure_devops.mdx index d22c9ae8257..d9d4b9b0128 100644 --- a/apps/docs/content/docs/en/triggers/azure_devops.mdx +++ b/apps/docs/content/docs/en/triggers/azure_devops.mdx @@ -31,7 +31,7 @@ Trigger workflow when an Azure DevOps build fails, is canceled, or partially suc | `branch` | string | Source branch name \(refs/heads/ prefix stripped\) | | `commitSha` | string | Source commit SHA | | `triggeredBy` | string | Display name of the person who triggered the build | -| `triggeredByEmail` | string | Email/unique name of the person who triggered the build | +| `triggeredByEmail` | string | Email/unique name of the person who triggered the build, or null if not set | | `startTime` | string | Build start time \(ISO 8601\) | | `finishTime` | string | Build finish time \(ISO 8601\) | | `buildUrl` | string | API URL for the build resource | @@ -72,12 +72,12 @@ Trigger workflow when a work item is created in Azure DevOps | `workItemType` | string | Work item type for Basic process \(e.g. Issue, Task, Epic\) | | `title` | string | Work item title | | `state` | string | Work item state for Basic process \(e.g. To Do, Doing, Done\) | -| `createdBy` | string | Display name of the creator | -| `assignedTo` | string | Assignee display name, or empty string if unassigned | +| `createdBy` | string | Display name of the creator, or null if not set | +| `assignedTo` | string | Assignee display name, or null if unassigned | | `priority` | number | Priority \(1–4\), or 0 if not set | | `areaPath` | string | Area path | | `iterationPath` | string | Iteration path | -| `description` | string | Work item description \(HTML\), or empty string if not set | +| `description` | string | Work item description \(HTML\), or null if not set | | `projectName` | string | Azure DevOps project name | | `workItemUrl` | string | API URL for the work item resource | diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index 2394d741345..ff677b09153 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -57,7 +57,6 @@ import { EvernoteIcon, ExaAIIcon, ExtendIcon, - EyeIcon, FathomIcon, FindymailIcon, FirecrawlIcon, @@ -405,7 +404,6 @@ export const blockTypeToIconMap: Record = { upstash: UpstashIcon, vercel: VercelIcon, video_generator_v3: VideoIcon, - vision_v2: EyeIcon, wealthbox: WealthboxIcon, webflow: WebflowIcon, whatsapp: WhatsAppIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index fdf1e75c533..bb13c23c653 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -1938,7 +1938,7 @@ }, { "name": "Get Work Items Batch", - "description": "Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. " + "description": "Fetch full details for multiple work items by ID from Azure DevOps. Pass comma-separated IDs (e.g. " }, { "name": "Create Work Item", @@ -4156,10 +4156,6 @@ "name": "Fetch", "description": "Fetch and parse a file from a URL with optional custom headers." }, - { - "name": "Get", - "description": "Get a workspace file object from a selected file or canonical workspace file ID." - }, { "name": "Write", "description": "Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., " @@ -4169,7 +4165,7 @@ "description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file." } ], - "operationCount": 5, + "operationCount": 4, "triggers": [], "triggerCount": 0, "authType": "none", @@ -14253,24 +14249,6 @@ "integrationTypes": ["ai", "design"], "tags": ["video-generation", "llm"] }, - { - "type": "vision_v2", - "slug": "vision", - "name": "Vision", - "description": "Analyze images with vision models", - "longDescription": "Integrate Vision into the workflow. Can analyze images with vision models.", - "bgColor": "#4D5FFF", - "iconName": "EyeIcon", - "docsUrl": "https://docs.sim.ai/tools/vision", - "operations": [], - "operationCount": 0, - "triggers": [], - "triggerCount": 0, - "authType": "api-key", - "category": "tools", - "integrationTypes": ["ai", "documents"], - "tags": ["llm", "document-processing", "ocr"] - }, { "type": "wealthbox", "slug": "wealthbox", diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index d48e5dffd80..73df740ff4e 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -23,6 +23,7 @@ import { import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' const logger = createLogger('ImageProxyAPI') @@ -42,6 +43,7 @@ interface GeneratedImageResult { revisedPrompt?: string seed?: number jobId?: string + falaiCost?: FalAICostMetadata } interface StoredImageResponse { @@ -61,6 +63,8 @@ interface StoredImageResponse { jobId?: string contentType: string } + __falaiCostDollars?: number + __falaiBilling?: FalAICostMetadata } export const POST = withRouteHandler(async (request: NextRequest) => { @@ -869,6 +873,13 @@ async function generateWithFalAI( revisedPrompt: getStringProperty(resultData, 'revised_prompt'), seed: getNumberProperty(resultData, 'seed'), jobId: falRequestId, + falaiCost: body.useHostedCostTracking + ? await getFalAICostMetadata({ + apiKey, + endpointId: modelConfig.endpoint, + requestId: falRequestId, + }) + : undefined, } } @@ -926,6 +937,8 @@ async function storeGeneratedImage( jobId: imageResult.jobId, contentType: imageResult.contentType, }, + __falaiCostDollars: imageResult.falaiCost?.costDollars, + __falaiBilling: imageResult.falaiCost, } } @@ -958,5 +971,7 @@ async function storeGeneratedImage( jobId: imageResult.jobId, contentType: imageResult.contentType, }, + __falaiCostDollars: imageResult.falaiCost?.costDollars, + __falaiBilling: imageResult.falaiCost, } } diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 693a6e192c2..fcc5c8ca1a7 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -8,6 +8,7 @@ import { getValidationErrorMessage, parseRequest, validationErrorResponse } from import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' @@ -102,6 +103,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let height: number | undefined let jobId: string | undefined let actualDuration: number | undefined + let falaiCost: FalAICostMetadata | undefined if (body.visualReference) { const denied = await assertToolFileAccess( @@ -200,6 +202,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { resolution, body.promptOptimizer, body.generateAudio, + body.useHostedCostTracking === true, requestId, logger ) @@ -208,6 +211,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { height = result.height jobId = result.jobId actualDuration = result.duration + falaiCost = result.falaiCost } else { return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 }) } @@ -262,6 +266,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { provider, model: model || 'default', jobId, + __falaiCostDollars: falaiCost?.costDollars, + __falaiBilling: falaiCost, }) } @@ -294,6 +300,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { provider, model: model || 'default', jobId, + __falaiCostDollars: falaiCost?.costDollars, + __falaiBilling: falaiCost, }) } catch (error) { logger.error(`[${requestId}] Video proxy error:`, error) @@ -1082,9 +1090,17 @@ async function generateWithFalAI( resolution: string | undefined, promptOptimizer: boolean | undefined, generateAudio: boolean | undefined, + useHostedCostTracking: boolean, requestId: string, logger: ReturnType -): Promise<{ buffer: Buffer; width: number; height: number; jobId: string; duration: number }> { +): Promise<{ + buffer: Buffer + width: number + height: number + jobId: string + duration: number + falaiCost?: FalAICostMetadata +}> { logger.info(`[${requestId}] Starting Fal.ai generation with model: ${model}`) const modelConfig = FALAI_MODEL_CONFIGS[model] @@ -1229,6 +1245,13 @@ async function generateWithFalAI( height, jobId: requestIdFal, duration: getNumberProperty(videoOutput, 'duration') || duration || 5, + falaiCost: useHostedCostTracking + ? await getFalAICostMetadata({ + apiKey, + endpointId: modelConfig.endpoint, + requestId: requestIdFal, + }) + : undefined, } } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 306b62850cb..527d9262251 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -23,6 +23,7 @@ import { FireworksIcon, GeminiIcon, GoogleIcon, + ImageIcon, JinaAIIcon, LinkupIcon, MistralIcon, @@ -85,6 +86,13 @@ const PROVIDERS: { description: 'LLM calls', placeholder: 'Enter your Fireworks API key', }, + { + id: 'falai', + name: 'Fal.ai', + icon: ImageIcon, + description: 'Image and video generation', + placeholder: 'Enter your Fal.ai API key', + }, { id: 'firecrawl', name: 'Firecrawl', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 0d05ff2a24a..cd2cad75fb0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -204,6 +204,12 @@ interface BlockData { position: { x: number; y: number } } +interface AddBlockFromToolbarDetail { + type?: unknown + enableTriggerMode?: unknown + presetOperation?: unknown +} + /** * Main workflow canvas content component. * Renders the ReactFlow canvas with blocks, edges, and all interactive features. @@ -2004,7 +2010,7 @@ const WorkflowContent = React.memo( /** Handles toolbar block click events to add blocks to the canvas. */ useEffect(() => { - const handleAddBlockFromToolbar = (event: CustomEvent) => { + const handleAddBlockFromToolbar = (event: CustomEvent) => { // Check if user has permission to interact with blocks if (!effectivePermissions.canEdit) { return @@ -2012,7 +2018,7 @@ const WorkflowContent = React.memo( const { type, enableTriggerMode, presetOperation } = event.detail - if (!type) return + if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return const basePosition = getViewportCenter() @@ -2070,8 +2076,10 @@ const WorkflowContent = React.memo( undefined, undefined, autoConnectEdge, - enableTriggerMode, - presetOperation ? { operation: presetOperation } : undefined + enableTriggerMode === true, + typeof presetOperation === 'string' && presetOperation + ? { operation: presetOperation } + : undefined ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 1808418c608..d39c0f06d8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -206,7 +206,10 @@ export function SearchModal({ type === 'trigger' && block.config ? hasTriggerCapability(block.config) : false window.dispatchEvent( new CustomEvent('add-block-from-toolbar', { - detail: { type: block.type, enableTriggerMode }, + detail: { + type: block.type, + enableTriggerMode, + }, }) ) captureEvent(posthogRef.current, 'search_result_selected', { @@ -376,12 +379,12 @@ export function SearchModal({ const filteredBlocks = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(blocks, (b) => `${b.name} block-${b.id}`, deferredSearch) + return filterAndSort(blocks, (b) => b.searchValue ?? `${b.name} block-${b.id}`, deferredSearch) }, [isOnWorkflowPage, blocks, deferredSearch]) const filteredTools = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(tools, (t) => `${t.name} tool-${t.id}`, deferredSearch) + return filterAndSort(tools, (t) => t.searchValue ?? `${t.name} tool-${t.id}`, deferredSearch) }, [isOnWorkflowPage, tools, deferredSearch]) const filteredTriggers = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 8fd9fae2109..c2464b043ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -104,6 +104,7 @@ import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFolderStore } from '@/stores/folders/store' import { useSearchModalStore } from '@/stores/modals/search/store' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' +import { useProvidersStore } from '@/stores/providers' import { useSidebarStore } from '@/stores/sidebar/store' const logger = createLogger('Sidebar') @@ -360,10 +361,15 @@ export const Sidebar = memo(function Sidebar() { const { config: permissionConfig, filterBlocks } = usePermissionConfig() const { navigateToSettings, getSettingsHref } = useSettingsNavigation() const initializeSearchData = useSearchModalStore((state) => state.initializeData) + const providerModelSignature = useProvidersStore((state) => + Object.values(state.providers) + .map((provider) => provider.models.join('\x00')) + .join('\x01') + ) useEffect(() => { initializeSearchData(filterBlocks) - }, [initializeSearchData, filterBlocks]) + }, [initializeSearchData, filterBlocks, providerModelSignature]) const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth) const isCollapsed = useSidebarStore((state) => state.isCollapsed) diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 8906f962ef3..105ca6020ea 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -734,6 +734,65 @@ describe.concurrent('Blocks Module', () => { expect(temperatureSubBlock?.min).toBe(0) expect(temperatureSubBlock?.max).toBe(2) }) + + it('should mark generator provider dropdowns as command-searchable', () => { + const imageGeneratorBlock = getBlock('image_generator_v2') + const videoGeneratorBlock = getBlock('video_generator_v3') + + const imageProviderSubBlock = imageGeneratorBlock?.subBlocks.find( + (sb) => sb.id === 'provider' + ) + const videoProviderSubBlock = videoGeneratorBlock?.subBlocks.find( + (sb) => sb.id === 'provider' + ) + const imageProviderOptions = imageProviderSubBlock?.options + const videoProviderOptions = videoProviderSubBlock?.options + + expect(imageGeneratorBlock?.hideFromToolbar).not.toBe(true) + expect(videoGeneratorBlock?.hideFromToolbar).not.toBe(true) + expect(imageProviderSubBlock?.commandSearchable).toBe(true) + expect(videoProviderSubBlock?.commandSearchable).toBe(true) + expect(imageProviderSubBlock?.value?.()).toBe('falai') + expect(videoProviderSubBlock?.value?.()).toBe('falai') + expect( + Array.isArray(imageProviderOptions) ? imageProviderOptions.map((option) => option.id) : [] + ).toContain('falai') + expect( + Array.isArray(videoProviderOptions) ? videoProviderOptions.map((option) => option.id) : [] + ).toContain('falai') + expect(getBlock('image_generator')?.hideFromToolbar).toBe(true) + expect(getBlock('video_generator_v2')?.hideFromToolbar).toBe(true) + }) + + it('should mark the agent model combobox as command-searchable', () => { + const agentBlock = getBlock('agent') + const modelSubBlock = agentBlock?.subBlocks.find((sb) => sb.id === 'model') + + expect(agentBlock?.hideFromToolbar).not.toBe(true) + expect(modelSubBlock?.type).toBe('combobox') + expect(modelSubBlock?.commandSearchable).toBe(true) + }) + + it('should hide generator API keys on hosted only for Fal.ai providers', () => { + for (const blockType of ['image_generator_v2', 'video_generator_v3']) { + const block = getBlock(blockType) + const apiKeySubBlocks = block?.subBlocks.filter((sb) => sb.id === 'apiKey') ?? [] + + const falApiKeySubBlock = apiKeySubBlocks.find( + (sb) => sb.condition?.field === 'provider' && sb.condition.value === 'falai' + ) + const nonFalApiKeySubBlock = apiKeySubBlocks.find( + (sb) => + sb.condition?.field === 'provider' && + sb.condition.value === 'falai' && + sb.condition.not === true + ) + + expect(falApiKeySubBlock?.hideWhenHosted).toBe(true) + expect(nonFalApiKeySubBlock).toBeDefined() + expect(nonFalApiKeySubBlock?.hideWhenHosted).not.toBe(true) + } + }) }) describe('Block Consistency', () => { diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index b5068dfeece..30b506edcc7 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -133,6 +133,7 @@ Return ONLY the JSON array.`, required: true, defaultValue: 'claude-sonnet-4-6', options: getModelOptions, + commandSearchable: true, }, { id: 'attachmentFiles', diff --git a/apps/sim/blocks/blocks/image_generator.ts b/apps/sim/blocks/blocks/image_generator.ts index be543494f53..5e095803128 100644 --- a/apps/sim/blocks/blocks/image_generator.ts +++ b/apps/sim/blocks/blocks/image_generator.ts @@ -333,7 +333,8 @@ export const ImageGeneratorV2Block: BlockConfig = { { label: 'Google Gemini', id: 'gemini' }, { label: 'Fal.ai (Multi-Model)', id: 'falai' }, ], - value: () => 'openai', + commandSearchable: true, + value: () => 'falai', }, { id: 'model', @@ -812,6 +813,18 @@ export const ImageGeneratorV2Block: BlockConfig = { placeholder: 'Enter your provider API key', password: true, connectionDroppable: false, + hideWhenHosted: true, + condition: { field: 'provider', value: 'falai' }, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your provider API key', + password: true, + connectionDroppable: false, + condition: { field: 'provider', value: 'falai', not: true }, }, ], tools: { @@ -819,14 +832,13 @@ export const ImageGeneratorV2Block: BlockConfig = { config: { tool: () => 'image_generate', params: (params) => { - if (!params.apiKey) { + const provider = params.provider || 'openai' + if (provider !== 'falai' && !params.apiKey) { throw new Error('API key is required') } if (!params.prompt) { throw new Error('Prompt is required') } - - const provider = params.provider || 'openai' const defaultModel = provider === 'gemini' ? 'gemini-3.1-flash-image-preview' diff --git a/apps/sim/blocks/blocks/video_generator.ts b/apps/sim/blocks/blocks/video_generator.ts index 4476891c297..86ab18e70d6 100644 --- a/apps/sim/blocks/blocks/video_generator.ts +++ b/apps/sim/blocks/blocks/video_generator.ts @@ -98,7 +98,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: 'MiniMax Hailuo', id: 'minimax' }, { label: 'Fal.ai (Multi-Model)', id: 'falai' }, ], - value: () => 'runway', + value: () => 'falai', required: true, }, @@ -776,6 +776,17 @@ export const VideoGeneratorBlock: BlockConfig = { placeholder: 'Enter your provider API key', password: true, required: true, + hideWhenHosted: true, + condition: { field: 'provider', value: 'falai' }, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your provider API key', + password: true, + required: true, + condition: { field: 'provider', value: 'falai', not: true }, }, ], @@ -894,7 +905,8 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: 'MiniMax Hailuo', id: 'minimax' }, { label: 'Fal.ai (Multi-Model)', id: 'falai' }, ], - value: () => 'runway', + commandSearchable: true, + value: () => 'falai', required: true, }, { @@ -1537,6 +1549,17 @@ export const VideoGeneratorV2Block: BlockConfig = { placeholder: 'Enter your provider API key', password: true, required: true, + hideWhenHosted: true, + condition: { field: 'provider', value: 'falai' }, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your provider API key', + password: true, + required: true, + condition: { field: 'provider', value: 'falai', not: true }, }, ], tools: { diff --git a/apps/sim/blocks/blocks/vision.ts b/apps/sim/blocks/blocks/vision.ts index 46c910ad8f4..0ab0f2934fc 100644 --- a/apps/sim/blocks/blocks/vision.ts +++ b/apps/sim/blocks/blocks/vision.ts @@ -109,7 +109,7 @@ export const VisionV2Block: BlockConfig = { type: 'vision_v2', name: 'Vision', description: 'Analyze images with vision models', - hideFromToolbar: false, + hideFromToolbar: true, tools: { access: ['vision_tool_v2'], config: { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 4125cb1e928..010be57690a 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -305,6 +305,8 @@ export interface SubBlockConfig { multiSelect?: boolean // Combobox specific: Enable search input in dropdown searchable?: boolean + /** Dropdown-specific: include static options as Cmd K search entries that preset this subblock. */ + commandSearchable?: boolean // Wand configuration for AI assistance wandConfig?: { enabled: boolean diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index d427e506f32..7a761c44bd3 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -7,6 +7,7 @@ export const byokProviderIdSchema = z.enum([ 'google', 'mistral', 'fireworks', + 'falai', 'firecrawl', 'exa', 'serper', diff --git a/apps/sim/lib/api/contracts/tools/media/image.ts b/apps/sim/lib/api/contracts/tools/media/image.ts index 0172a899cbe..81ac09927e7 100644 --- a/apps/sim/lib/api/contracts/tools/media/image.ts +++ b/apps/sim/lib/api/contracts/tools/media/image.ts @@ -37,6 +37,7 @@ export const imageToolBodySchema = z workflowId: z.string().optional(), executionId: z.string().optional(), userId: z.string().optional(), + useHostedCostTracking: z.boolean().optional(), }) .passthrough() diff --git a/apps/sim/lib/api/contracts/tools/media/video.ts b/apps/sim/lib/api/contracts/tools/media/video.ts index 222e9385237..24122d5f2b3 100644 --- a/apps/sim/lib/api/contracts/tools/media/video.ts +++ b/apps/sim/lib/api/contracts/tools/media/video.ts @@ -29,6 +29,7 @@ export const videoToolBodySchema = z workflowId: z.string().optional(), executionId: z.string().optional(), userId: z.string().optional(), + useHostedCostTracking: z.boolean().optional(), }) .passthrough() diff --git a/apps/sim/lib/tools/falai-pricing.ts b/apps/sim/lib/tools/falai-pricing.ts new file mode 100644 index 00000000000..7848ce4fd6f --- /dev/null +++ b/apps/sim/lib/tools/falai-pricing.ts @@ -0,0 +1,140 @@ +import { sleep } from '@sim/utils/helpers' + +export const FALAI_HOSTED_KEY_MARKUP_MULTIPLIER = 1.5 + +export interface FalAICostMetadata { + endpointId: string + requestId: string + costDollars: number + source: 'billing_events' | 'historical_estimate' + outputUnits?: number | null + unitPrice?: number | null + percentDiscount?: number | null + currency?: string +} + +interface FalAIBillingEvent { + request_id: string + endpoint_id: string + output_units: number | null + unit_price: number | null + percent_discount: number | null + cost_estimate_nano_usd: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function parseBillingEvent(value: unknown): FalAIBillingEvent | undefined { + if (!isRecord(value)) return undefined + + const requestId = value.request_id + const endpointId = value.endpoint_id + const costEstimateNanoUsd = getNumber(value.cost_estimate_nano_usd) + + if (typeof requestId !== 'string' || typeof endpointId !== 'string') return undefined + if (costEstimateNanoUsd === undefined) return undefined + + return { + request_id: requestId, + endpoint_id: endpointId, + output_units: getNumber(value.output_units) ?? null, + unit_price: getNumber(value.unit_price) ?? null, + percent_discount: getNumber(value.percent_discount) ?? null, + cost_estimate_nano_usd: costEstimateNanoUsd, + } +} + +async function fetchFalAIBillingEvent( + apiKey: string, + requestId: string +): Promise { + const url = new URL('https://api.fal.ai/v1/models/billing-events') + url.searchParams.set('request_id', requestId) + url.searchParams.set('limit', '1') + + const response = await fetch(url, { + headers: { + Authorization: `Key ${apiKey}`, + }, + }) + + if (!response.ok) return undefined + + const data = (await response.json()) as unknown + if (!isRecord(data) || !Array.isArray(data.billing_events)) return undefined + + return data.billing_events.map(parseBillingEvent).find(Boolean) +} + +async function estimateFalAICallCost(apiKey: string, endpointId: string): Promise { + const response = await fetch('https://api.fal.ai/v1/models/pricing/estimate', { + method: 'POST', + headers: { + Authorization: `Key ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + estimate_type: 'historical_api_price', + endpoints: { + [endpointId]: { + call_quantity: 1, + }, + }, + }), + }) + + if (!response.ok) { + const error = await response.text().catch(() => '') + throw new Error(`Fal.ai pricing estimate failed: ${response.status} ${error}`) + } + + const data = (await response.json()) as unknown + const totalCost = isRecord(data) ? getNumber(data.total_cost) : undefined + if (totalCost === undefined) { + throw new Error('Fal.ai pricing estimate missing total_cost') + } + + return totalCost +} + +export async function getFalAICostMetadata({ + apiKey, + endpointId, + requestId, +}: { + apiKey: string + endpointId: string + requestId: string +}): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + const event = await fetchFalAIBillingEvent(apiKey, requestId) + if (event) { + return { + endpointId: event.endpoint_id, + requestId: event.request_id, + costDollars: event.cost_estimate_nano_usd / 1_000_000_000, + source: 'billing_events', + outputUnits: event.output_units, + unitPrice: event.unit_price, + percentDiscount: event.percent_discount, + currency: 'USD', + } + } + + await sleep(1000) + } + + return { + endpointId, + requestId, + costDollars: await estimateFalAICallCost(apiKey, endpointId), + source: 'historical_estimate', + currency: 'USD', + } +} diff --git a/apps/sim/stores/modals/search/store.test.ts b/apps/sim/stores/modals/search/store.test.ts new file mode 100644 index 00000000000..36492582e1d --- /dev/null +++ b/apps/sim/stores/modals/search/store.test.ts @@ -0,0 +1,151 @@ +import { createElement, type SVGProps } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockConfig } from '@/blocks/types' + +const { mockGetAllBlocks, mockGetToolOperationsIndex, mockGetTriggersForSidebar } = vi.hoisted( + () => ({ + mockGetAllBlocks: vi.fn(), + mockGetToolOperationsIndex: vi.fn(() => []), + mockGetTriggersForSidebar: vi.fn(() => []), + }) +) + +vi.mock('@/blocks', () => ({ + getAllBlocks: mockGetAllBlocks, +})) + +vi.mock('@/lib/search/tool-operations', () => ({ + getToolOperationsIndex: mockGetToolOperationsIndex, +})) + +vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({ + getTriggersForSidebar: mockGetTriggersForSidebar, +})) + +import { + buildCommandSearchableOptionSearchValue, + useSearchModalStore, +} from '@/stores/modals/search/store' + +function TestIcon(props: SVGProps) { + return createElement('svg', props) +} + +function createBlock(overrides: Partial = {}): BlockConfig { + return { + type: 'image_generator_v2', + name: 'Image Generator', + description: 'Generate images', + category: 'tools', + bgColor: '#4D5FFF', + icon: TestIcon, + subBlocks: [ + { + id: 'provider', + title: 'Provider', + type: 'dropdown', + commandSearchable: true, + options: [ + { label: 'OpenAI', id: 'openai' }, + { label: 'Fal.ai (Multi-Model)', id: 'falai' }, + { label: 'Hidden Provider', id: 'hidden', hidden: true }, + ], + }, + ], + tools: { access: ['image_generate'] }, + inputs: {}, + outputs: {}, + ...overrides, + } +} + +describe('search modal store', () => { + beforeEach(() => { + vi.clearAllMocks() + useSearchModalStore.setState({ + isOpen: false, + data: { + blocks: [], + tools: [], + triggers: [], + toolOperations: [], + docs: [], + isInitialized: false, + }, + }) + }) + + describe('buildCommandSearchableOptionSearchValue', () => { + it('builds search terms for marked static dropdown options', () => { + const block = createBlock() + const searchValue = buildCommandSearchableOptionSearchValue(block) + + expect(searchValue).toContain('Provider') + expect(searchValue).toContain('Fal.ai (Multi-Model)') + expect(searchValue).toContain('falai') + expect(searchValue).not.toContain('Hidden Provider') + expect(searchValue).not.toContain('hidden') + }) + + it('does not index dropdowns that only use in-dropdown search', () => { + const block = createBlock({ + subBlocks: [ + { + id: 'timezone', + title: 'Timezone', + type: 'dropdown', + searchable: true, + options: [{ label: 'UTC', id: 'utc' }], + }, + ], + }) + + expect(buildCommandSearchableOptionSearchValue(block)).toBe('') + }) + + it('builds search terms for marked combobox option functions', () => { + const block = createBlock({ + subBlocks: [ + { + id: 'model', + title: 'Model', + type: 'combobox', + commandSearchable: true, + options: () => [ + { label: 'claude-sonnet-4-6', id: 'claude-sonnet-4-6' }, + { label: 'Hidden Model', id: 'hidden-model', hidden: true }, + ], + }, + ], + }) + + const searchValue = buildCommandSearchableOptionSearchValue(block) + + expect(searchValue).toContain('Model') + expect(searchValue).toContain('claude-sonnet-4-6') + expect(searchValue).not.toContain('Hidden Model') + expect(searchValue).not.toContain('hidden-model') + }) + }) + + it('adds command-searchable options to visible block search values without extra rows', () => { + const visibleBlock = createBlock() + const hiddenBlock = createBlock({ + type: 'hidden_generator', + hideFromToolbar: true, + }) + + mockGetAllBlocks.mockReturnValue([visibleBlock, hiddenBlock]) + + useSearchModalStore.getState().initializeData((blocks) => blocks) + + const { tools } = useSearchModalStore.getState().data + expect(tools).toHaveLength(1) + expect(tools[0]).toEqual( + expect.objectContaining({ + id: 'image_generator_v2', + searchValue: expect.stringContaining('Fal.ai (Multi-Model)'), + }) + ) + }) +}) diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 5a928246d34..ad2bb6004a2 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -4,6 +4,7 @@ import { devtools } from 'zustand/middleware' import { getToolOperationsIndex } from '@/lib/search/tool-operations' import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils' import { getAllBlocks } from '@/blocks' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import type { SearchBlockItem, SearchData, @@ -21,6 +22,47 @@ const initialData: SearchData = { isInitialized: false, } +type CommandSearchableOption = { + label: string + id: string + hidden?: boolean +} + +function getCommandSearchableOptions(subBlock: SubBlockConfig): CommandSearchableOption[] { + if (!subBlock.options) return [] + + try { + const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options + return Array.isArray(options) ? options : [] + } catch { + return [] + } +} + +export function buildCommandSearchableOptionSearchValue(block: BlockConfig): string { + const terms = new Set() + + for (const subBlock of block.subBlocks) { + if ( + (subBlock.type !== 'dropdown' && subBlock.type !== 'combobox') || + !subBlock.commandSearchable + ) { + continue + } + + for (const option of getCommandSearchableOptions(subBlock)) { + if (option.hidden) continue + + const subBlockTitle = subBlock.title ?? subBlock.id + terms.add(subBlockTitle) + terms.add(option.label) + terms.add(option.id) + } + } + + return Array.from(terms).join(' ') +} + export const useSearchModalStore = create()( devtools( (set, _) => ({ @@ -56,6 +98,7 @@ export const useSearchModalStore = create()( icon: block.icon, bgColor: block.bgColor || '#6B7280', type: block.type, + searchValue: `${block.name} ${block.type} block-${block.type} ${buildCommandSearchableOptionSearchValue(block)}`, } if (block.category === 'blocks' && block.type !== 'starter') { diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index ead6c2cb6a0..b276f23627a 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -11,6 +11,7 @@ export interface SearchBlockItem { bgColor: string type: string config?: BlockConfig + searchValue?: string } /** diff --git a/apps/sim/tools/falai-hosting.test.ts b/apps/sim/tools/falai-hosting.test.ts new file mode 100644 index 00000000000..70fa98be110 --- /dev/null +++ b/apps/sim/tools/falai-hosting.test.ts @@ -0,0 +1,61 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { FALAI_HOSTED_KEY_MARKUP_MULTIPLIER } from '@/lib/tools/falai-pricing' +import { imageGenerateTool } from '@/tools/image/generate' +import { falaiVideoTool } from '@/tools/video/falai' + +describe('Fal.ai hosted key pricing', () => { + it('applies hosted markup to image generation provider cost', () => { + const pricing = imageGenerateTool.hosting?.pricing + expect(pricing?.type).toBe('custom') + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + + const result = pricing.getCost( + {}, + { + __falaiCostDollars: 0.1, + __falaiBilling: { + source: 'billing_events', + endpointId: 'fal-ai/nano-banana-2', + }, + } + ) + + expect(typeof result).toBe('object') + if (typeof result === 'number') throw new Error('Expected structured pricing result') + expect(result.cost).toBeCloseTo(0.1 * FALAI_HOSTED_KEY_MARKUP_MULTIPLIER) + expect(result.metadata).toMatchObject({ + providerCostDollars: 0.1, + markupMultiplier: FALAI_HOSTED_KEY_MARKUP_MULTIPLIER, + source: 'billing_events', + }) + }) + + it('applies hosted markup to video generation provider cost', () => { + const pricing = falaiVideoTool.hosting?.pricing + expect(pricing?.type).toBe('custom') + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + + const result = pricing.getCost( + {}, + { + __falaiCostDollars: 0.4, + __falaiBilling: { + source: 'billing_events', + endpointId: 'fal-ai/veo3.1', + }, + } + ) + + expect(typeof result).toBe('object') + if (typeof result === 'number') throw new Error('Expected structured pricing result') + expect(result.cost).toBeCloseTo(0.4 * FALAI_HOSTED_KEY_MARKUP_MULTIPLIER) + expect(result.metadata).toMatchObject({ + providerCostDollars: 0.4, + markupMultiplier: FALAI_HOSTED_KEY_MARKUP_MULTIPLIER, + source: 'billing_events', + }) + }) +}) diff --git a/apps/sim/tools/image/generate.ts b/apps/sim/tools/image/generate.ts index e67de7e5428..d100d84d66f 100644 --- a/apps/sim/tools/image/generate.ts +++ b/apps/sim/tools/image/generate.ts @@ -1,3 +1,4 @@ +import { FALAI_HOSTED_KEY_MARKUP_MULTIPLIER } from '@/lib/tools/falai-pricing' import type { ImageGenerationParams, ImageGenerationResponse } from '@/tools/image/types' import type { ToolConfig } from '@/tools/types' @@ -113,6 +114,38 @@ export const imageGenerateTool: ToolConfig params.provider === 'falai', + envKeyPrefix: 'FALAI_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'falai', + pricing: { + type: 'custom', + getCost: (_params, output) => { + const providerCostDollars = output.__falaiCostDollars + if (typeof providerCostDollars !== 'number' || Number.isNaN(providerCostDollars)) { + throw new Error('Fal.ai image response missing cost data') + } + + return { + cost: providerCostDollars * FALAI_HOSTED_KEY_MARKUP_MULTIPLIER, + metadata: { + ...(typeof output.__falaiBilling === 'object' && output.__falaiBilling !== null + ? (output.__falaiBilling as Record) + : {}), + providerCostDollars, + markupMultiplier: FALAI_HOSTED_KEY_MARKUP_MULTIPLIER, + }, + } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 40, + burstMultiplier: 1, + }, + }, + request: { url: '/api/tools/image', method: 'POST', @@ -122,6 +155,7 @@ export const imageGenerateTool: ToolConfig ({ provider: params.provider, @@ -144,6 +178,7 @@ export const imageGenerateTool: ToolConfig { Object.assign(tools, originalTools) }) + + it('should skip hosted key injection when hosting predicate is false', async () => { + const mockTool = { + id: 'test_conditional_hosting', + name: 'Test Conditional Hosting', + description: 'A test tool with conditional hosted keys', + version: '1.0.0', + params: { + provider: { type: 'string', required: false }, + apiKey: { type: 'string', required: false }, + }, + hosting: { + enabled: (params: { provider?: string }) => params.provider === 'hosted-provider', + envKeyPrefix: 'TEST_HOSTED_KEY', + apiKeyParam: 'apiKey', + pricing: { + type: 'per_request' as const, + cost: 0.005, + }, + rateLimit: { + mode: 'per_request' as const, + requestsPerMinute: 100, + }, + }, + request: { + url: '/api/test/conditional-hosting', + method: 'POST' as const, + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'success' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_conditional_hosting = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ success: true }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + const mockContext = createToolExecutionContext({ + userId: 'user-123', + } as any) + const result = await executeTool( + 'test_conditional_hosting', + { provider: 'user-provider' }, + { executionContext: mockContext } + ) + + expect(result.success).toBe(true) + expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled() + expect(result.output.cost).toBeUndefined() + + Object.assign(tools, originalTools) + }) + + it('should skip hosted key injection when user provides an API key', async () => { + const mockTool = { + id: 'test_user_key_priority', + name: 'Test User Key Priority', + description: 'A test tool where user keys should win', + version: '1.0.0', + params: { + apiKey: { type: 'string', required: false }, + }, + hosting: { + envKeyPrefix: 'TEST_HOSTED_KEY', + apiKeyParam: 'apiKey', + pricing: { + type: 'per_request' as const, + cost: 0.005, + }, + rateLimit: { + mode: 'per_request' as const, + requestsPerMinute: 100, + }, + }, + request: { + url: '/api/test/user-key-priority', + method: 'POST' as const, + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'success' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_user_key_priority = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ success: true }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + const mockContext = createToolExecutionContext({ + userId: 'user-123', + } as any) + const result = await executeTool( + 'test_user_key_priority', + { apiKey: 'user-api-key' }, + { executionContext: mockContext } + ) + + expect(result.success).toBe(true) + expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled() + expect(result.output.cost).toBeUndefined() + + Object.assign(tools, originalTools) + }) }) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index de5adb1dc7a..5ecb598c93d 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -228,8 +228,15 @@ async function injectHostedKeyIfNeeded( ): Promise { if (!tool.hosting) return { isUsingHostedKey: false } if (!isHosted) return { isUsingHostedKey: false } + if (tool.hosting.enabled && !tool.hosting.enabled(params)) { + return { isUsingHostedKey: false } + } const { envKeyPrefix, apiKeyParam, byokProviderId, rateLimit } = tool.hosting + const userProvidedKey = params[apiKeyParam] + if (typeof userProvidedKey === 'string' && userProvidedKey.trim().length > 0) { + return { isUsingHostedKey: false } + } const { workspaceId, userId, workflowId } = resolveToolScope(params, executionContext) @@ -295,6 +302,7 @@ async function injectHostedKeyIfNeeded( } params[apiKeyParam] = acquireResult.key + params.__usingHostedKey = true logger.info(`[${requestId}] Using hosted key for ${tool.id} (${acquireResult.envVarName})`, { keyIndex: acquireResult.keyIndex, provider, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 5cd428c941e..6b39f863954 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -8,6 +8,7 @@ export type BYOKProviderId = | 'google' | 'mistral' | 'fireworks' + | 'falai' | 'firecrawl' | 'exa' | 'serper' @@ -307,6 +308,8 @@ export type ToolHostingPricing

> = PerRequestPricing * no code changes needed. */ interface ToolHostingConfig

> { + /** Optional predicate for tools where hosted keys only apply to some parameter combinations. */ + enabled?: (params: P) => boolean /** * Env var name prefix for hosted keys. * At runtime, `{envKeyPrefix}_COUNT` is read to determine how many keys exist, diff --git a/apps/sim/tools/video/falai.ts b/apps/sim/tools/video/falai.ts index 77999d87820..b2314472f4d 100644 --- a/apps/sim/tools/video/falai.ts +++ b/apps/sim/tools/video/falai.ts @@ -1,3 +1,4 @@ +import { FALAI_HOSTED_KEY_MARKUP_MULTIPLIER } from '@/lib/tools/falai-pricing' import type { ToolConfig } from '@/tools/types' import type { VideoParams, VideoResponse } from '@/tools/video/types' import { parseBooleanParam, parseBooleanParamWithDefault } from '@/tools/video/utils' @@ -68,6 +69,37 @@ export const falaiVideoTool: ToolConfig = { }, }, + hosting: { + envKeyPrefix: 'FALAI_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'falai', + pricing: { + type: 'custom', + getCost: (_params, output) => { + const providerCostDollars = output.__falaiCostDollars + if (typeof providerCostDollars !== 'number' || Number.isNaN(providerCostDollars)) { + throw new Error('Fal.ai video response missing cost data') + } + + return { + cost: providerCostDollars * FALAI_HOSTED_KEY_MARKUP_MULTIPLIER, + metadata: { + ...(typeof output.__falaiBilling === 'object' && output.__falaiBilling !== null + ? (output.__falaiBilling as Record) + : {}), + providerCostDollars, + markupMultiplier: FALAI_HOSTED_KEY_MARKUP_MULTIPLIER, + }, + } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 40, + burstMultiplier: 1, + }, + }, + request: { url: '/api/tools/video', method: 'POST', @@ -77,6 +109,7 @@ export const falaiVideoTool: ToolConfig = { body: ( params: VideoParams & { _context?: { workspaceId?: string; workflowId?: string; executionId?: string } + __usingHostedKey?: boolean } ) => ({ provider: 'falai', @@ -91,6 +124,7 @@ export const falaiVideoTool: ToolConfig = { workspaceId: params._context?.workspaceId, workflowId: params._context?.workflowId, executionId: params._context?.executionId, + useHostedCostTracking: params.__usingHostedKey === true, }), }, @@ -128,6 +162,8 @@ export const falaiVideoTool: ToolConfig = { provider: 'falai', model: data.model, jobId: data.jobId, + __falaiCostDollars: data.__falaiCostDollars, + __falaiBilling: data.__falaiBilling, }, } }, diff --git a/apps/sim/tools/video/types.ts b/apps/sim/tools/video/types.ts index 5d31e29e2c7..50d47d91c48 100644 --- a/apps/sim/tools/video/types.ts +++ b/apps/sim/tools/video/types.ts @@ -33,6 +33,16 @@ export interface VideoResponse extends ToolResponse { provider?: string model?: string jobId?: string + __falaiCostDollars?: number + __falaiBilling?: { + endpointId: string + requestId: string + source: 'billing_events' | 'historical_estimate' + outputUnits?: number | null + unitPrice?: number | null + percentDiscount?: number | null + currency?: string + } } } From 3c7fd5793c0b450b5f416a7fb3fd93fa0e0c25e4 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 20 May 2026 16:33:51 -0700 Subject: [PATCH 2/2] address comments --- .../w/components/sidebar/sidebar.tsx | 11 +- apps/sim/lib/tools/falai-pricing.ts | 123 +++++++++++++----- apps/sim/tools/falai-hosting.test.ts | 51 +++++++- apps/sim/tools/image/types.ts | 3 +- apps/sim/tools/video/types.ts | 3 +- 5 files changed, 154 insertions(+), 37 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index c2464b043ff..c9ba8668b11 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -361,10 +361,13 @@ export const Sidebar = memo(function Sidebar() { const { config: permissionConfig, filterBlocks } = usePermissionConfig() const { navigateToSettings, getSettingsHref } = useSettingsNavigation() const initializeSearchData = useSearchModalStore((state) => state.initializeData) - const providerModelSignature = useProvidersStore((state) => - Object.values(state.providers) - .map((provider) => provider.models.join('\x00')) - .join('\x01') + const providers = useProvidersStore((state) => state.providers) + const providerModelSignature = useMemo( + () => + Object.values(providers) + .map((provider) => provider.models.join('\x00')) + .join('\x01'), + [providers] ) useEffect(() => { diff --git a/apps/sim/lib/tools/falai-pricing.ts b/apps/sim/lib/tools/falai-pricing.ts index 7848ce4fd6f..f94f4d5d060 100644 --- a/apps/sim/lib/tools/falai-pricing.ts +++ b/apps/sim/lib/tools/falai-pricing.ts @@ -1,16 +1,24 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' export const FALAI_HOSTED_KEY_MARKUP_MULTIPLIER = 1.5 +export const FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS = 0.05 +export const FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS = 0.25 +const FALAI_BILLING_EVENT_ATTEMPTS = 2 +const FALAI_BILLING_EVENT_RETRY_MS = 500 +const logger = createLogger('FalAIPricing') export interface FalAICostMetadata { endpointId: string requestId: string costDollars: number - source: 'billing_events' | 'historical_estimate' + source: 'billing_events' | 'historical_estimate' | 'fallback_floor' outputUnits?: number | null unitPrice?: number | null percentDiscount?: number | null currency?: string + error?: string } interface FalAIBillingEvent { @@ -30,6 +38,20 @@ function getNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } +function getFalAIFallbackProviderCostDollars(endpointId: string): number { + const normalizedEndpointId = endpointId.toLowerCase() + const isImageEndpoint = + normalizedEndpointId.includes('image') || + normalizedEndpointId.includes('nano-banana') || + normalizedEndpointId.includes('seedream') || + normalizedEndpointId.includes('flux') || + normalizedEndpointId.includes('grok-imagine') + + return isImageEndpoint + ? FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS + : FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS +} + function parseBillingEvent(value: unknown): FalAIBillingEvent | undefined { if (!isRecord(value)) return undefined @@ -58,49 +80,72 @@ async function fetchFalAIBillingEvent( url.searchParams.set('request_id', requestId) url.searchParams.set('limit', '1') - const response = await fetch(url, { - headers: { - Authorization: `Key ${apiKey}`, - }, - }) + let response: Response + try { + response = await fetch(url, { + headers: { + Authorization: `Key ${apiKey}`, + }, + }) + } catch (error) { + logger.warn('Failed to fetch Fal.ai billing event', { + requestId, + error: getErrorMessage(error, 'Unknown error'), + }) + return undefined + } if (!response.ok) return undefined - const data = (await response.json()) as unknown + const data = await response.json().catch((error) => { + logger.warn('Failed to parse Fal.ai billing event response', { + requestId, + error: getErrorMessage(error, 'Unknown error'), + }) + return undefined + }) if (!isRecord(data) || !Array.isArray(data.billing_events)) return undefined return data.billing_events.map(parseBillingEvent).find(Boolean) } -async function estimateFalAICallCost(apiKey: string, endpointId: string): Promise { - const response = await fetch('https://api.fal.ai/v1/models/pricing/estimate', { - method: 'POST', - headers: { - Authorization: `Key ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - estimate_type: 'historical_api_price', - endpoints: { - [endpointId]: { - call_quantity: 1, - }, +async function estimateFalAICallCost( + apiKey: string, + endpointId: string +): Promise<{ costDollars?: number; error?: string }> { + let response: Response + try { + response = await fetch('https://api.fal.ai/v1/models/pricing/estimate', { + method: 'POST', + headers: { + Authorization: `Key ${apiKey}`, + 'Content-Type': 'application/json', }, - }), - }) + body: JSON.stringify({ + estimate_type: 'historical_api_price', + endpoints: { + [endpointId]: { + call_quantity: 1, + }, + }, + }), + }) + } catch (error) { + return { error: getErrorMessage(error, 'Unknown error') } + } if (!response.ok) { const error = await response.text().catch(() => '') - throw new Error(`Fal.ai pricing estimate failed: ${response.status} ${error}`) + return { error: `Fal.ai pricing estimate failed: ${response.status} ${error}` } } const data = (await response.json()) as unknown const totalCost = isRecord(data) ? getNumber(data.total_cost) : undefined if (totalCost === undefined) { - throw new Error('Fal.ai pricing estimate missing total_cost') + return { error: 'Fal.ai pricing estimate missing total_cost' } } - return totalCost + return { costDollars: totalCost } } export async function getFalAICostMetadata({ @@ -112,7 +157,7 @@ export async function getFalAICostMetadata({ endpointId: string requestId: string }): Promise { - for (let attempt = 0; attempt < 5; attempt++) { + for (let attempt = 0; attempt < FALAI_BILLING_EVENT_ATTEMPTS; attempt++) { const event = await fetchFalAIBillingEvent(apiKey, requestId) if (event) { return { @@ -127,14 +172,34 @@ export async function getFalAICostMetadata({ } } - await sleep(1000) + if (attempt < FALAI_BILLING_EVENT_ATTEMPTS - 1) { + await sleep(FALAI_BILLING_EVENT_RETRY_MS) + } + } + + const estimate = await estimateFalAICallCost(apiKey, endpointId) + if (estimate.costDollars !== undefined) { + return { + endpointId, + requestId, + costDollars: estimate.costDollars, + source: 'historical_estimate', + currency: 'USD', + } } + logger.warn('Fal.ai cost metadata unavailable after generation completed', { + endpointId, + requestId, + error: estimate.error, + }) + return { endpointId, requestId, - costDollars: await estimateFalAICallCost(apiKey, endpointId), - source: 'historical_estimate', + costDollars: getFalAIFallbackProviderCostDollars(endpointId), + source: 'fallback_floor', currency: 'USD', + error: estimate.error, } } diff --git a/apps/sim/tools/falai-hosting.test.ts b/apps/sim/tools/falai-hosting.test.ts index 70fa98be110..d3df0abfdd2 100644 --- a/apps/sim/tools/falai-hosting.test.ts +++ b/apps/sim/tools/falai-hosting.test.ts @@ -1,11 +1,20 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' -import { FALAI_HOSTED_KEY_MARKUP_MULTIPLIER } from '@/lib/tools/falai-pricing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + FALAI_HOSTED_KEY_MARKUP_MULTIPLIER, + FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS, + getFalAICostMetadata, +} from '@/lib/tools/falai-pricing' import { imageGenerateTool } from '@/tools/image/generate' import { falaiVideoTool } from '@/tools/video/falai' +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + describe('Fal.ai hosted key pricing', () => { it('applies hosted markup to image generation provider cost', () => { const pricing = imageGenerateTool.hosting?.pricing @@ -58,4 +67,42 @@ describe('Fal.ai hosted key pricing', () => { source: 'billing_events', }) }) + + it('returns fallback floor cost metadata instead of throwing when billing and estimate fail', async () => { + vi.useFakeTimers() + + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ billing_events: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ billing_events: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce(new Response('pricing unavailable', { status: 500 })) + vi.stubGlobal('fetch', mockFetch) + + const resultPromise = getFalAICostMetadata({ + apiKey: 'fal-key', + endpointId: 'fal-ai/nano-banana-2', + requestId: 'request-1', + }) + + await vi.advanceTimersByTimeAsync(500) + const result = await resultPromise + + expect(result).toMatchObject({ + endpointId: 'fal-ai/nano-banana-2', + requestId: 'request-1', + costDollars: FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS, + source: 'fallback_floor', + currency: 'USD', + }) + }) }) diff --git a/apps/sim/tools/image/types.ts b/apps/sim/tools/image/types.ts index ac5bd19606e..1a1f9e2416f 100644 --- a/apps/sim/tools/image/types.ts +++ b/apps/sim/tools/image/types.ts @@ -40,11 +40,12 @@ export interface ImageGenerationResponse extends ToolResponse { __falaiBilling?: { endpointId: string requestId: string - source: 'billing_events' | 'historical_estimate' + source: 'billing_events' | 'historical_estimate' | 'fallback_floor' outputUnits?: number | null unitPrice?: number | null percentDiscount?: number | null currency?: string + error?: string } } } diff --git a/apps/sim/tools/video/types.ts b/apps/sim/tools/video/types.ts index 50d47d91c48..861e9eabec4 100644 --- a/apps/sim/tools/video/types.ts +++ b/apps/sim/tools/video/types.ts @@ -37,11 +37,12 @@ export interface VideoResponse extends ToolResponse { __falaiBilling?: { endpointId: string requestId: string - source: 'billing_events' | 'historical_estimate' + source: 'billing_events' | 'historical_estimate' | 'fallback_floor' outputUnits?: number | null unitPrice?: number | null percentDiscount?: number | null currency?: string + error?: string } } }