From 2cdb89681b2a7525cee69a1e85f3b29ef570045d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Feb 2026 18:32:00 -0800 Subject: [PATCH] feat(hosted keys): Implement serper hosted key --- .../api/workspaces/[id]/byok-keys/route.ts | 2 +- .../hooks/use-editor-subblock-layout.ts | 4 + .../workflow-block/workflow-block.tsx | 2 + .../settings-modal/components/byok/byok.tsx | 9 +- apps/sim/blocks/blocks/serper.ts | 1 + apps/sim/blocks/types.ts | 5 + apps/sim/hooks/queries/byok-keys.ts | 2 +- apps/sim/lib/api-key/byok.ts | 2 +- .../sim/lib/workflows/subblocks/visibility.ts | 10 + apps/sim/tools/index.ts | 172 +++++++++++++++++- apps/sim/tools/serper/search.ts | 11 +- apps/sim/tools/types.ts | 68 +++++++ 12 files changed, 282 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index 307855535..301341315 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -12,7 +12,7 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per const logger = createLogger('WorkspaceBYOKKeysAPI') -const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral'] as const +const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'serper'] as const const UpsertKeySchema = z.object({ providerId: z.enum(VALID_PROVIDERS), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts index 50d3f416e..9f81bb395 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts @@ -3,6 +3,7 @@ import { buildCanonicalIndex, evaluateSubBlockCondition, isSubBlockFeatureEnabled, + isSubBlockHiddenByHostedKey, isSubBlockVisibleForMode, } from '@/lib/workflows/subblocks/visibility' import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types' @@ -108,6 +109,9 @@ export function useEditorSubblockLayout( // Check required feature if specified - declarative feature gating if (!isSubBlockFeatureEnabled(block)) return false + // Hide tool API key fields when hosted key is available + if (isSubBlockHiddenByHostedKey(block)) return false + // Special handling for trigger-config type (legacy trigger configuration UI) if (block.type === ('trigger-config' as SubBlockType)) { const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index c0f89e2b3..339a535e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -15,6 +15,7 @@ import { evaluateSubBlockCondition, hasAdvancedValues, isSubBlockFeatureEnabled, + isSubBlockHiddenByHostedKey, isSubBlockVisibleForMode, resolveDependencyValue, } from '@/lib/workflows/subblocks/visibility' @@ -828,6 +829,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ if (block.hidden) return false if (block.hideFromPreview) return false if (!isSubBlockFeatureEnabled(block)) return false + if (isSubBlockHiddenByHostedKey(block)) return false const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx index b8304402b..e423094a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx @@ -13,7 +13,7 @@ import { ModalFooter, ModalHeader, } from '@/components/emcn' -import { AnthropicIcon, GeminiIcon, MistralIcon, OpenAIIcon } from '@/components/icons' +import { AnthropicIcon, GeminiIcon, MistralIcon, OpenAIIcon, SerperIcon } from '@/components/icons' import { Skeleton } from '@/components/ui' import { type BYOKKey, @@ -60,6 +60,13 @@ const PROVIDERS: { description: 'LLM calls and Knowledge Base OCR', placeholder: 'Enter your API key', }, + { + id: 'serper', + name: 'Serper', + icon: SerperIcon, + description: 'Web search tool', + placeholder: 'Enter your Serper API key', + }, ] function BYOKKeySkeleton() { diff --git a/apps/sim/blocks/blocks/serper.ts b/apps/sim/blocks/blocks/serper.ts index ed4eb2e6f..202de8ef7 100644 --- a/apps/sim/blocks/blocks/serper.ts +++ b/apps/sim/blocks/blocks/serper.ts @@ -78,6 +78,7 @@ export const SerperBlock: BlockConfig = { placeholder: 'Enter your Serper API key', password: true, required: true, + hideWhenHosted: true, }, ], tools: { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 08a716925..9523b543e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -243,6 +243,11 @@ export interface SubBlockConfig { hidden?: boolean hideFromPreview?: boolean // Hide this subblock from the workflow block preview requiresFeature?: string // Environment variable name that must be truthy for this subblock to be visible + /** + * Hide this subblock when running on hosted Sim (isHosted is true). + * Used for tool API key fields that should be hidden when Sim provides hosted keys. + */ + hideWhenHosted?: boolean description?: string tooltip?: string // Tooltip text displayed via info icon next to the title value?: (params: Record) => string diff --git a/apps/sim/hooks/queries/byok-keys.ts b/apps/sim/hooks/queries/byok-keys.ts index 26d348d5a..8abeaebbd 100644 --- a/apps/sim/hooks/queries/byok-keys.ts +++ b/apps/sim/hooks/queries/byok-keys.ts @@ -4,7 +4,7 @@ import { API_ENDPOINTS } from '@/stores/constants' const logger = createLogger('BYOKKeysQueries') -export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' +export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' export interface BYOKKey { id: string diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 04a35adb4..540d618f9 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -10,7 +10,7 @@ import { useProvidersStore } from '@/stores/providers/store' const logger = createLogger('BYOKKeys') -export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' +export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' export interface BYOKKeyResult { apiKey: string diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 1ce0076b4..ac39244c2 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -1,4 +1,5 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' +import { isHosted } from '@/lib/core/config/feature-flags' import type { SubBlockConfig } from '@/blocks/types' export type CanonicalMode = 'basic' | 'advanced' @@ -270,3 +271,12 @@ export function isSubBlockFeatureEnabled(subBlock: SubBlockConfig): boolean { if (!subBlock.requiresFeature) return true return isTruthy(getEnv(subBlock.requiresFeature)) } + +/** + * Check if a subblock should be hidden because we're running on hosted Sim. + * Used for tool API key fields that should be hidden when Sim provides hosted keys. + */ +export function isSubBlockHiddenByHostedKey(subBlock: SubBlockConfig): boolean { + if (!subBlock.hideWhenHosted) return false + return isHosted +} diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 040a40a27..b94d796d5 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1,5 +1,8 @@ import { createLogger } from '@sim/logger' import { generateInternalToken } from '@/lib/auth/internal' +import { getBYOKKey } from '@/lib/api-key/byok' +import { logFixedUsage } from '@/lib/billing/core/usage-log' +import { env } from '@/lib/core/config/env' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' import { secureFetchWithPinnedIP, @@ -13,7 +16,12 @@ import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' import type { ExecutionContext } from '@/executor/types' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' -import type { OAuthTokenPayload, ToolConfig, ToolResponse } from '@/tools/types' +import type { + OAuthTokenPayload, + ToolConfig, + ToolHostingPricing, + ToolResponse, +} from '@/tools/types' import { formatRequestParams, getTool, @@ -23,6 +31,150 @@ import { const logger = createLogger('Tools') +/** + * Get a hosted API key from environment variables + * Supports rotation when multiple keys are configured + */ +function getHostedKeyFromEnv(envKeys: string[]): string | null { + const keys = envKeys + .map((key) => env[key as keyof typeof env]) + .filter((value): value is string => Boolean(value)) + + if (keys.length === 0) return null + + // Round-robin rotation based on current minute + const currentMinute = Math.floor(Date.now() / 60000) + const keyIndex = currentMinute % keys.length + + return keys[keyIndex] +} + +/** + * Inject hosted API key if tool supports it and user didn't provide one. + * Checks BYOK workspace keys first, then falls back to hosted env keys. + * Returns whether a hosted (billable) key was injected. + */ +async function injectHostedKeyIfNeeded( + tool: ToolConfig, + params: Record, + executionContext: ExecutionContext | undefined, + requestId: string +): Promise { + if (!tool.hosting) return false + + const { envKeys, apiKeyParam, byokProviderId } = tool.hosting + const userProvidedKey = params[apiKeyParam] + + if (userProvidedKey) { + logger.debug(`[${requestId}] User provided API key for ${tool.id}, skipping hosted key`) + return false + } + + // Check BYOK workspace key first + if (byokProviderId && executionContext?.workspaceId) { + try { + const byokResult = await getBYOKKey( + executionContext.workspaceId, + byokProviderId as 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' + ) + if (byokResult) { + params[apiKeyParam] = byokResult.apiKey + logger.info(`[${requestId}] Using BYOK key for ${tool.id}`) + return false // Don't bill - user's own key + } + } catch (error) { + logger.error(`[${requestId}] Failed to get BYOK key for ${tool.id}:`, error) + // Fall through to hosted key + } + } + + // Fall back to hosted env key + const hostedKey = getHostedKeyFromEnv(envKeys) + if (!hostedKey) { + logger.debug(`[${requestId}] No hosted key available for ${tool.id}`) + return false + } + + params[apiKeyParam] = hostedKey + logger.info(`[${requestId}] Using hosted key for ${tool.id}`) + return true // Bill the user +} + +/** + * Calculate cost based on pricing model + */ +function calculateToolCost( + pricing: ToolHostingPricing, + params: Record, + response: Record +): number { + switch (pricing.type) { + case 'per_request': + return pricing.cost + + case 'per_unit': { + const usage = pricing.getUsage(params, response) + return usage * pricing.costPerUnit + } + + case 'per_result': { + const resultCount = pricing.getResultCount(response) + const billableResults = pricing.maxResults + ? Math.min(resultCount, pricing.maxResults) + : resultCount + return billableResults * pricing.costPerResult + } + + case 'per_second': { + const duration = pricing.getDuration(response) + const billableDuration = pricing.minimumSeconds + ? Math.max(duration, pricing.minimumSeconds) + : duration + return billableDuration * pricing.costPerSecond + } + + default: { + const exhaustiveCheck: never = pricing + throw new Error(`Unknown pricing type: ${(exhaustiveCheck as ToolHostingPricing).type}`) + } + } +} + +/** + * Log usage for a tool that used a hosted API key + */ +async function logHostedToolUsage( + tool: ToolConfig, + params: Record, + response: Record, + executionContext: ExecutionContext | undefined, + requestId: string +): Promise { + if (!tool.hosting?.pricing || !executionContext?.userId) { + return + } + + const cost = calculateToolCost(tool.hosting.pricing, params, response) + + if (cost <= 0) return + + try { + await logFixedUsage({ + userId: executionContext.userId, + source: 'workflow', + description: `tool:${tool.id}`, + cost, + workspaceId: executionContext.workspaceId, + workflowId: executionContext.workflowId, + executionId: executionContext.executionId, + }) + logger.debug(`[${requestId}] Logged hosted tool usage for ${tool.id}: $${cost}`) + } catch (error) { + logger.error(`[${requestId}] Failed to log hosted tool usage for ${tool.id}:`, error) + // Don't throw - usage logging should not break the main flow + } +} + /** * Normalizes a tool ID by stripping resource ID suffix (UUID). * Workflow tools: 'workflow_executor_' -> 'workflow_executor' @@ -279,6 +431,14 @@ export async function executeTool( throw new Error(`Tool not found: ${toolId}`) } + // Inject hosted API key if tool supports it and user didn't provide one + const isUsingHostedKey = await injectHostedKeyIfNeeded( + tool, + contextParams, + executionContext, + requestId + ) + // If we have a credential parameter, fetch the access token if (contextParams.credential) { logger.info( @@ -387,6 +547,11 @@ export async function executeTool( // Process file outputs if execution context is available finalResult = await processFileOutputs(finalResult, tool, executionContext) + // Log usage for hosted key if execution was successful + if (isUsingHostedKey && finalResult.success) { + await logHostedToolUsage(tool, contextParams, finalResult.output, executionContext, requestId) + } + // Add timing data to the result const endTime = new Date() const endTimeISO = endTime.toISOString() @@ -420,6 +585,11 @@ export async function executeTool( // Process file outputs if execution context is available finalResult = await processFileOutputs(finalResult, tool, executionContext) + // Log usage for hosted key if execution was successful + if (isUsingHostedKey && finalResult.success) { + await logHostedToolUsage(tool, contextParams, finalResult.output, executionContext, requestId) + } + // Add timing data to the result const endTime = new Date() const endTimeISO = endTime.toISOString() diff --git a/apps/sim/tools/serper/search.ts b/apps/sim/tools/serper/search.ts index 685c2b643..81861c495 100644 --- a/apps/sim/tools/serper/search.ts +++ b/apps/sim/tools/serper/search.ts @@ -43,11 +43,20 @@ export const searchTool: ToolConfig = { }, apiKey: { type: 'string', - required: true, + required: false, visibility: 'user-only', description: 'Serper API Key', }, }, + hosting: { + envKeys: ['SERPER_API_KEY'], + apiKeyParam: 'apiKey', + byokProviderId: 'serper', + pricing: { + type: 'per_request', + cost: 0.001, // $0.001 per search (Serper pricing: ~$50/50k searches) + }, + }, request: { url: (params) => `https://google.serper.dev/${params.type || 'search'}`, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 72b2ffa21..b020c2775 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -127,6 +127,13 @@ export interface ToolConfig

{ * Maps param IDs to their enrichment configuration. */ schemaEnrichment?: Record + + /** + * Hosted API key configuration for this tool. + * When configured, the tool can use Sim's hosted API keys if user doesn't provide their own. + * Usage is billed according to the pricing config. + */ + hosting?: ToolHostingConfig } export interface TableRow { @@ -170,3 +177,64 @@ export interface SchemaEnrichmentConfig { required?: string[] } | null> } + +/** + * Pricing models for hosted API key usage + */ +/** Flat fee per API call (e.g., Serper search) */ +export interface PerRequestPricing { + type: 'per_request' + /** Cost per request in dollars */ + cost: number +} + +/** Usage-based on input/output size (e.g., LLM tokens, TTS characters) */ +export interface PerUnitPricing { + type: 'per_unit' + /** Cost per unit in dollars */ + costPerUnit: number + /** Unit of measurement */ + unit: 'token' | 'character' | 'byte' | 'kb' | 'mb' + /** Extract usage count from params (before execution) or response (after execution) */ + getUsage: (params: Record, response?: Record) => number +} + +/** Based on result count (e.g., per search result, per email sent) */ +export interface PerResultPricing { + type: 'per_result' + /** Cost per result in dollars */ + costPerResult: number + /** Maximum results to bill for (cap) */ + maxResults?: number + /** Extract result count from response */ + getResultCount: (response: Record) => number +} + +/** Billed by execution duration (e.g., browser sessions, video processing) */ +export interface PerSecondPricing { + type: 'per_second' + /** Cost per second in dollars */ + costPerSecond: number + /** Minimum billable seconds */ + minimumSeconds?: number + /** Extract duration from response (in seconds) */ + getDuration: (response: Record) => number +} + +/** Union of all pricing models */ +export type ToolHostingPricing = PerRequestPricing | PerUnitPricing | PerResultPricing | PerSecondPricing + +/** + * Configuration for hosted API key support + * When configured, the tool can use Sim's hosted API keys if user doesn't provide their own + */ +export interface ToolHostingConfig { + /** Environment variable names to check for hosted keys (supports rotation with multiple keys) */ + envKeys: string[] + /** The parameter name that receives the API key */ + apiKeyParam: string + /** BYOK provider ID for workspace key lookup (e.g., 'serper') */ + byokProviderId?: string + /** Pricing when using hosted key */ + pricing: ToolHostingPricing +}