Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
2cdb896
feat(hosted keys): Implement serper hosted key
Feb 13, 2026
3e6527a
Handle required fields correctly for hosted keys
Feb 13, 2026
e5c8aec
Add rate limiting (3 tries, exponential backoff)
Feb 13, 2026
8a78f80
Add custom pricing, switch to exa as first hosted key
Feb 13, 2026
d174a6a
Add telemetry
Feb 13, 2026
c12e92c
Consolidate byok type definitions
Feb 13, 2026
2a36143
Add warning comment if default calculation is used
Feb 13, 2026
36e6464
Record usage to user stats table
Feb 13, 2026
f237d6f
Fix unit tests, use cost property
Feb 13, 2026
0a002fd
Include more metadata in cost output
Feb 13, 2026
36d49ef
Fix disabled tests
Feb 13, 2026
fbd1cdf
Fix spacing
Feb 14, 2026
dc4c611
Fix lint
Feb 14, 2026
68da290
Move knowledge cost restructuring away from generic block handler
Feb 16, 2026
ce02a30
Migrate knowledge unit tests
Feb 16, 2026
e6d98c6
Lint
Feb 16, 2026
ecdbe29
Fix broken tests
Mar 5, 2026
2325535
Merge branch 'staging' into feat/sim-provided-key
Mar 5, 2026
693a3d3
Add user based hosted key throttling
Mar 5, 2026
242d6e0
Refactor hosted key handling. Add optimistic handling of throttling f…
Mar 5, 2026
7b8e24e
Remove research as hosted key. Recommend BYOK if throtttling occurs
Mar 5, 2026
cd160d3
Make adding api keys adjustable via env vars
Mar 6, 2026
2082bc4
Remove vestigial fields from research
Mar 6, 2026
a90777a
Make billing actor id required for throttling
Mar 6, 2026
d7ea0af
Switch to round robin for api key distribution
Mar 6, 2026
1c5425e
Add helper method for adding hosted key cost
Mar 6, 2026
3832e5c
Strip leading double underscores to avoid breaking change
Mar 6, 2026
34cffdc
Lint fix
Mar 6, 2026
612ea7c
Remove falsy check in favor for explicit null check
Mar 6, 2026
a0fc749
Add more detailed metrics for different throttling types
Mar 6, 2026
5d04ae5
Fix _costDollars field
Mar 6, 2026
8eaf401
Handle hosted agent tool calls
Mar 7, 2026
ee2e123
Fail loudly if cost field isn't found
Mar 7, 2026
09a1b5c
Remove any type
Mar 7, 2026
0836131
Fix type error
Mar 7, 2026
427627a
Fix lint
Mar 7, 2026
d29d613
Fix usage log double logging data
Mar 7, 2026
3e94ce3
Fix test
Mar 7, 2026
1ccaae6
Add browseruse hosted key
Mar 6, 2026
74f0191
Add firecrawl and serper hosted keys
Mar 6, 2026
158d523
feat(hosted key): Add exa hosted key (#3221)
TheodoreSpeaks Mar 7, 2026
8137357
Fail fast on cost data not being found
Mar 7, 2026
b96074c
Add hosted key for google services
Mar 7, 2026
0b6c8a9
Add hosting configuration and pricing logic for ElevenLabs TTS tools
Mar 7, 2026
6c9bd07
Add linkup hosted key
Mar 7, 2026
945f7ea
Add jina hosted key
Mar 7, 2026
ce602ce
Add hugging face hosted key
Mar 7, 2026
ed1a142
Add perplexity hosting
Mar 7, 2026
e07cfe2
Add broader metrics for throttling
Mar 7, 2026
8d18eee
Add skill for adding hosted key
Mar 7, 2026
1ac08e5
Merge branch 'staging' into feat/hosted-key-agent
Mar 7, 2026
d7a124a
Lint, remove vestigial hosted keys not implemented
Mar 7, 2026
2280b47
Revert agent changes
Mar 7, 2026
af9d64a
fail fast
Mar 7, 2026
c1b729f
Fix build issue
Mar 7, 2026
4ee4e98
Fix build issues
Mar 7, 2026
829b8d4
Fix type error
Mar 7, 2026
8829ac3
Remove byok types that aren't implemented
Mar 7, 2026
2cccfdd
Address feedback
Mar 7, 2026
05eccf1
Use default model when model id isn't provided
Mar 7, 2026
4b073a6
Fix cost default issues
Mar 7, 2026
540aa18
Remove firecrawl error suppression
Mar 7, 2026
9f676bc
Restore original behavior for hugging face
Mar 7, 2026
a463ebc
Add mistral hosted key
Mar 9, 2026
824b602
Merge branch 'feat/mothership-copilot' into feat/hosted-key-agent
Mar 10, 2026
2743063
Merge feat/mothership-copilot into feat/hosted-key-agent (prefer ours)
Mar 10, 2026
d5120b0
Remove hugging face hosted key
Mar 10, 2026
594a800
Fix pricing mismatch is mistral and perplexity
Mar 10, 2026
2293153
Add hosted keys for parallel and brand fetch
Mar 10, 2026
32c791b
Add brandfetch hosted key
Mar 10, 2026
1ea8f83
Update types
Mar 10, 2026
87f6070
Change byok name to parallel_ai
Mar 10, 2026
bfa96d8
Add telemetry on unknown models
Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(hosted keys): Implement serper hosted key
  • Loading branch information
Theodore Li committed Feb 13, 2026
commit 2cdb89681b2a7525cee69a1e85f3b29ef570045d
2 changes: 1 addition & 1 deletion apps/sim/app/api/workspaces/[id]/byok-keys/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
buildCanonicalIndex,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockHiddenByHostedKey,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
evaluateSubBlockCondition,
hasAdvancedValues,
isSubBlockFeatureEnabled,
isSubBlockHiddenByHostedKey,
isSubBlockVisibleForMode,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
Expand Down Expand Up @@ -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'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/blocks/blocks/serper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const SerperBlock: BlockConfig<SearchResponse> = {
placeholder: 'Enter your Serper API key',
password: true,
required: true,
hideWhenHosted: true,
},
],
tools: {
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, any>) => string
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/byok-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api-key/byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions apps/sim/lib/workflows/subblocks/visibility.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
172 changes: 171 additions & 1 deletion apps/sim/tools/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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<string, unknown>,
executionContext: ExecutionContext | undefined,
requestId: string
): Promise<boolean> {
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<string, unknown>,
response: Record<string, unknown>
): 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<string, unknown>,
response: Record<string, unknown>,
executionContext: ExecutionContext | undefined,
requestId: string
): Promise<void> {
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_<uuid>' -> 'workflow_executor'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 10 additions & 1 deletion apps/sim/tools/serper/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,20 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
},
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'}`,
Expand Down
Loading