Skip to content

Commit 2cdb896

Browse files
author
Theodore Li
committed
feat(hosted keys): Implement serper hosted key
1 parent ebc2ffa commit 2cdb896

File tree

12 files changed

+282
-6
lines changed

12 files changed

+282
-6
lines changed

apps/sim/app/api/workspaces/[id]/byok-keys/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per
1212

1313
const logger = createLogger('WorkspaceBYOKKeysAPI')
1414

15-
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral'] as const
15+
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'serper'] as const
1616

1717
const UpsertKeySchema = z.object({
1818
providerId: z.enum(VALID_PROVIDERS),

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
buildCanonicalIndex,
44
evaluateSubBlockCondition,
55
isSubBlockFeatureEnabled,
6+
isSubBlockHiddenByHostedKey,
67
isSubBlockVisibleForMode,
78
} from '@/lib/workflows/subblocks/visibility'
89
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
@@ -108,6 +109,9 @@ export function useEditorSubblockLayout(
108109
// Check required feature if specified - declarative feature gating
109110
if (!isSubBlockFeatureEnabled(block)) return false
110111

112+
// Hide tool API key fields when hosted key is available
113+
if (isSubBlockHiddenByHostedKey(block)) return false
114+
111115
// Special handling for trigger-config type (legacy trigger configuration UI)
112116
if (block.type === ('trigger-config' as SubBlockType)) {
113117
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
evaluateSubBlockCondition,
1616
hasAdvancedValues,
1717
isSubBlockFeatureEnabled,
18+
isSubBlockHiddenByHostedKey,
1819
isSubBlockVisibleForMode,
1920
resolveDependencyValue,
2021
} from '@/lib/workflows/subblocks/visibility'
@@ -828,6 +829,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
828829
if (block.hidden) return false
829830
if (block.hideFromPreview) return false
830831
if (!isSubBlockFeatureEnabled(block)) return false
832+
if (isSubBlockHiddenByHostedKey(block)) return false
831833

832834
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
833835

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
ModalFooter,
1414
ModalHeader,
1515
} from '@/components/emcn'
16-
import { AnthropicIcon, GeminiIcon, MistralIcon, OpenAIIcon } from '@/components/icons'
16+
import { AnthropicIcon, GeminiIcon, MistralIcon, OpenAIIcon, SerperIcon } from '@/components/icons'
1717
import { Skeleton } from '@/components/ui'
1818
import {
1919
type BYOKKey,
@@ -60,6 +60,13 @@ const PROVIDERS: {
6060
description: 'LLM calls and Knowledge Base OCR',
6161
placeholder: 'Enter your API key',
6262
},
63+
{
64+
id: 'serper',
65+
name: 'Serper',
66+
icon: SerperIcon,
67+
description: 'Web search tool',
68+
placeholder: 'Enter your Serper API key',
69+
},
6370
]
6471

6572
function BYOKKeySkeleton() {

apps/sim/blocks/blocks/serper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const SerperBlock: BlockConfig<SearchResponse> = {
7878
placeholder: 'Enter your Serper API key',
7979
password: true,
8080
required: true,
81+
hideWhenHosted: true,
8182
},
8283
],
8384
tools: {

apps/sim/blocks/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ export interface SubBlockConfig {
243243
hidden?: boolean
244244
hideFromPreview?: boolean // Hide this subblock from the workflow block preview
245245
requiresFeature?: string // Environment variable name that must be truthy for this subblock to be visible
246+
/**
247+
* Hide this subblock when running on hosted Sim (isHosted is true).
248+
* Used for tool API key fields that should be hidden when Sim provides hosted keys.
249+
*/
250+
hideWhenHosted?: boolean
246251
description?: string
247252
tooltip?: string // Tooltip text displayed via info icon next to the title
248253
value?: (params: Record<string, any>) => string

apps/sim/hooks/queries/byok-keys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { API_ENDPOINTS } from '@/stores/constants'
44

55
const logger = createLogger('BYOKKeysQueries')
66

7-
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral'
7+
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper'
88

99
export interface BYOKKey {
1010
id: string

apps/sim/lib/api-key/byok.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useProvidersStore } from '@/stores/providers/store'
1010

1111
const logger = createLogger('BYOKKeys')
1212

13-
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral'
13+
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper'
1414

1515
export interface BYOKKeyResult {
1616
apiKey: string

apps/sim/lib/workflows/subblocks/visibility.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getEnv, isTruthy } from '@/lib/core/config/env'
2+
import { isHosted } from '@/lib/core/config/feature-flags'
23
import type { SubBlockConfig } from '@/blocks/types'
34

45
export type CanonicalMode = 'basic' | 'advanced'
@@ -270,3 +271,12 @@ export function isSubBlockFeatureEnabled(subBlock: SubBlockConfig): boolean {
270271
if (!subBlock.requiresFeature) return true
271272
return isTruthy(getEnv(subBlock.requiresFeature))
272273
}
274+
275+
/**
276+
* Check if a subblock should be hidden because we're running on hosted Sim.
277+
* Used for tool API key fields that should be hidden when Sim provides hosted keys.
278+
*/
279+
export function isSubBlockHiddenByHostedKey(subBlock: SubBlockConfig): boolean {
280+
if (!subBlock.hideWhenHosted) return false
281+
return isHosted
282+
}

apps/sim/tools/index.ts

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { createLogger } from '@sim/logger'
22
import { generateInternalToken } from '@/lib/auth/internal'
3+
import { getBYOKKey } from '@/lib/api-key/byok'
4+
import { logFixedUsage } from '@/lib/billing/core/usage-log'
5+
import { env } from '@/lib/core/config/env'
36
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
47
import {
58
secureFetchWithPinnedIP,
@@ -13,7 +16,12 @@ import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver'
1316
import type { ExecutionContext } from '@/executor/types'
1417
import type { ErrorInfo } from '@/tools/error-extractors'
1518
import { extractErrorMessage } from '@/tools/error-extractors'
16-
import type { OAuthTokenPayload, ToolConfig, ToolResponse } from '@/tools/types'
19+
import type {
20+
OAuthTokenPayload,
21+
ToolConfig,
22+
ToolHostingPricing,
23+
ToolResponse,
24+
} from '@/tools/types'
1725
import {
1826
formatRequestParams,
1927
getTool,
@@ -23,6 +31,150 @@ import {
2331

2432
const logger = createLogger('Tools')
2533

34+
/**
35+
* Get a hosted API key from environment variables
36+
* Supports rotation when multiple keys are configured
37+
*/
38+
function getHostedKeyFromEnv(envKeys: string[]): string | null {
39+
const keys = envKeys
40+
.map((key) => env[key as keyof typeof env])
41+
.filter((value): value is string => Boolean(value))
42+
43+
if (keys.length === 0) return null
44+
45+
// Round-robin rotation based on current minute
46+
const currentMinute = Math.floor(Date.now() / 60000)
47+
const keyIndex = currentMinute % keys.length
48+
49+
return keys[keyIndex]
50+
}
51+
52+
/**
53+
* Inject hosted API key if tool supports it and user didn't provide one.
54+
* Checks BYOK workspace keys first, then falls back to hosted env keys.
55+
* Returns whether a hosted (billable) key was injected.
56+
*/
57+
async function injectHostedKeyIfNeeded(
58+
tool: ToolConfig,
59+
params: Record<string, unknown>,
60+
executionContext: ExecutionContext | undefined,
61+
requestId: string
62+
): Promise<boolean> {
63+
if (!tool.hosting) return false
64+
65+
const { envKeys, apiKeyParam, byokProviderId } = tool.hosting
66+
const userProvidedKey = params[apiKeyParam]
67+
68+
if (userProvidedKey) {
69+
logger.debug(`[${requestId}] User provided API key for ${tool.id}, skipping hosted key`)
70+
return false
71+
}
72+
73+
// Check BYOK workspace key first
74+
if (byokProviderId && executionContext?.workspaceId) {
75+
try {
76+
const byokResult = await getBYOKKey(
77+
executionContext.workspaceId,
78+
byokProviderId as 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper'
79+
)
80+
if (byokResult) {
81+
params[apiKeyParam] = byokResult.apiKey
82+
logger.info(`[${requestId}] Using BYOK key for ${tool.id}`)
83+
return false // Don't bill - user's own key
84+
}
85+
} catch (error) {
86+
logger.error(`[${requestId}] Failed to get BYOK key for ${tool.id}:`, error)
87+
// Fall through to hosted key
88+
}
89+
}
90+
91+
// Fall back to hosted env key
92+
const hostedKey = getHostedKeyFromEnv(envKeys)
93+
if (!hostedKey) {
94+
logger.debug(`[${requestId}] No hosted key available for ${tool.id}`)
95+
return false
96+
}
97+
98+
params[apiKeyParam] = hostedKey
99+
logger.info(`[${requestId}] Using hosted key for ${tool.id}`)
100+
return true // Bill the user
101+
}
102+
103+
/**
104+
* Calculate cost based on pricing model
105+
*/
106+
function calculateToolCost(
107+
pricing: ToolHostingPricing,
108+
params: Record<string, unknown>,
109+
response: Record<string, unknown>
110+
): number {
111+
switch (pricing.type) {
112+
case 'per_request':
113+
return pricing.cost
114+
115+
case 'per_unit': {
116+
const usage = pricing.getUsage(params, response)
117+
return usage * pricing.costPerUnit
118+
}
119+
120+
case 'per_result': {
121+
const resultCount = pricing.getResultCount(response)
122+
const billableResults = pricing.maxResults
123+
? Math.min(resultCount, pricing.maxResults)
124+
: resultCount
125+
return billableResults * pricing.costPerResult
126+
}
127+
128+
case 'per_second': {
129+
const duration = pricing.getDuration(response)
130+
const billableDuration = pricing.minimumSeconds
131+
? Math.max(duration, pricing.minimumSeconds)
132+
: duration
133+
return billableDuration * pricing.costPerSecond
134+
}
135+
136+
default: {
137+
const exhaustiveCheck: never = pricing
138+
throw new Error(`Unknown pricing type: ${(exhaustiveCheck as ToolHostingPricing).type}`)
139+
}
140+
}
141+
}
142+
143+
/**
144+
* Log usage for a tool that used a hosted API key
145+
*/
146+
async function logHostedToolUsage(
147+
tool: ToolConfig,
148+
params: Record<string, unknown>,
149+
response: Record<string, unknown>,
150+
executionContext: ExecutionContext | undefined,
151+
requestId: string
152+
): Promise<void> {
153+
if (!tool.hosting?.pricing || !executionContext?.userId) {
154+
return
155+
}
156+
157+
const cost = calculateToolCost(tool.hosting.pricing, params, response)
158+
159+
if (cost <= 0) return
160+
161+
try {
162+
await logFixedUsage({
163+
userId: executionContext.userId,
164+
source: 'workflow',
165+
description: `tool:${tool.id}`,
166+
cost,
167+
workspaceId: executionContext.workspaceId,
168+
workflowId: executionContext.workflowId,
169+
executionId: executionContext.executionId,
170+
})
171+
logger.debug(`[${requestId}] Logged hosted tool usage for ${tool.id}: $${cost}`)
172+
} catch (error) {
173+
logger.error(`[${requestId}] Failed to log hosted tool usage for ${tool.id}:`, error)
174+
// Don't throw - usage logging should not break the main flow
175+
}
176+
}
177+
26178
/**
27179
* Normalizes a tool ID by stripping resource ID suffix (UUID).
28180
* Workflow tools: 'workflow_executor_<uuid>' -> 'workflow_executor'
@@ -279,6 +431,14 @@ export async function executeTool(
279431
throw new Error(`Tool not found: ${toolId}`)
280432
}
281433

434+
// Inject hosted API key if tool supports it and user didn't provide one
435+
const isUsingHostedKey = await injectHostedKeyIfNeeded(
436+
tool,
437+
contextParams,
438+
executionContext,
439+
requestId
440+
)
441+
282442
// If we have a credential parameter, fetch the access token
283443
if (contextParams.credential) {
284444
logger.info(
@@ -387,6 +547,11 @@ export async function executeTool(
387547
// Process file outputs if execution context is available
388548
finalResult = await processFileOutputs(finalResult, tool, executionContext)
389549

550+
// Log usage for hosted key if execution was successful
551+
if (isUsingHostedKey && finalResult.success) {
552+
await logHostedToolUsage(tool, contextParams, finalResult.output, executionContext, requestId)
553+
}
554+
390555
// Add timing data to the result
391556
const endTime = new Date()
392557
const endTimeISO = endTime.toISOString()
@@ -420,6 +585,11 @@ export async function executeTool(
420585
// Process file outputs if execution context is available
421586
finalResult = await processFileOutputs(finalResult, tool, executionContext)
422587

588+
// Log usage for hosted key if execution was successful
589+
if (isUsingHostedKey && finalResult.success) {
590+
await logHostedToolUsage(tool, contextParams, finalResult.output, executionContext, requestId)
591+
}
592+
423593
// Add timing data to the result
424594
const endTime = new Date()
425595
const endTimeISO = endTime.toISOString()

0 commit comments

Comments
 (0)