Skip to content

Commit 158d523

Browse files
TheodoreSpeaksTheodore Li
andauthored
feat(hosted key): Add exa hosted key (#3221)
* feat(hosted keys): Implement serper hosted key * Handle required fields correctly for hosted keys * Add rate limiting (3 tries, exponential backoff) * Add custom pricing, switch to exa as first hosted key * Add telemetry * Consolidate byok type definitions * Add warning comment if default calculation is used * Record usage to user stats table * Fix unit tests, use cost property * Include more metadata in cost output * Fix disabled tests * Fix spacing * Fix lint * Move knowledge cost restructuring away from generic block handler * Migrate knowledge unit tests * Lint * Fix broken tests * Add user based hosted key throttling * Refactor hosted key handling. Add optimistic handling of throttling for custom throttle rules. * Remove research as hosted key. Recommend BYOK if throtttling occurs * Make adding api keys adjustable via env vars * Remove vestigial fields from research * Make billing actor id required for throttling * Switch to round robin for api key distribution * Add helper method for adding hosted key cost * Strip leading double underscores to avoid breaking change * Lint fix * Remove falsy check in favor for explicit null check * Add more detailed metrics for different throttling types * Fix _costDollars field * Handle hosted agent tool calls * Fail loudly if cost field isn't found * Remove any type * Fix type error * Fix lint * Fix usage log double logging data * Fix test --------- Co-authored-by: Theodore Li <teddy@zenobiapay.com>
1 parent 1ba1bc8 commit 158d523

File tree

52 files changed

+2840
-335
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+2840
-335
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
@@ -13,7 +13,7 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per
1313

1414
const logger = createLogger('WorkspaceBYOKKeysAPI')
1515

16-
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral'] as const
16+
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'exa'] as const
1717

1818
const UpsertKeySchema = z.object({
1919
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
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
@@ -16,6 +16,7 @@ import {
1616
evaluateSubBlockCondition,
1717
hasAdvancedValues,
1818
isSubBlockFeatureEnabled,
19+
isSubBlockHiddenByHostedKey,
1920
isSubBlockVisibleForMode,
2021
resolveDependencyValue,
2122
} from '@/lib/workflows/subblocks/visibility'
@@ -977,6 +978,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
977978
if (block.hidden) return false
978979
if (block.hideFromPreview) return false
979980
if (!isSubBlockFeatureEnabled(block)) return false
981+
if (isSubBlockHiddenByHostedKey(block)) return false
980982

981983
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
982984

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ import {
1313
ModalFooter,
1414
ModalHeader,
1515
} from '@/components/emcn'
16-
import { AnthropicIcon, GeminiIcon, MistralIcon, OpenAIIcon } from '@/components/icons'
16+
import { AnthropicIcon, ExaAIIcon, GeminiIcon, MistralIcon, OpenAIIcon } from '@/components/icons'
1717
import { Skeleton } from '@/components/ui'
1818
import {
1919
type BYOKKey,
20-
type BYOKProviderId,
2120
useBYOKKeys,
2221
useDeleteBYOKKey,
2322
useUpsertBYOKKey,
2423
} from '@/hooks/queries/byok-keys'
24+
import type { BYOKProviderId } from '@/tools/types'
2525

2626
const logger = createLogger('BYOKSettings')
2727

@@ -60,6 +60,13 @@ const PROVIDERS: {
6060
description: 'LLM calls and Knowledge Base OCR',
6161
placeholder: 'Enter your API key',
6262
},
63+
{
64+
id: 'exa',
65+
name: 'Exa',
66+
icon: ExaAIIcon,
67+
description: 'AI-powered search and research',
68+
placeholder: 'Enter your Exa API key',
69+
},
6370
]
6471

6572
function BYOKKeySkeleton() {

apps/sim/blocks/blocks/exa.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,14 +309,26 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
309309
value: () => 'exa-research',
310310
condition: { field: 'operation', value: 'exa_research' },
311311
},
312-
// API Key (common)
312+
// API Key — hidden when hosted for operations with hosted key support
313313
{
314314
id: 'apiKey',
315315
title: 'API Key',
316316
type: 'short-input',
317317
placeholder: 'Enter your Exa API key',
318318
password: true,
319319
required: true,
320+
hideWhenHosted: true,
321+
condition: { field: 'operation', value: 'exa_research', not: true },
322+
},
323+
// API Key — always visible for research (no hosted key support)
324+
{
325+
id: 'apiKey',
326+
title: 'API Key',
327+
type: 'short-input',
328+
placeholder: 'Enter your Exa API key',
329+
password: true,
330+
required: true,
331+
condition: { field: 'operation', value: 'exa_research' },
320332
},
321333
],
322334
tools: {

apps/sim/blocks/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export interface SubBlockConfig {
253253
hidden?: boolean
254254
hideFromPreview?: boolean // Hide this subblock from the workflow block preview
255255
requiresFeature?: string // Environment variable name that must be truthy for this subblock to be visible
256+
hideWhenHosted?: boolean // Hide this subblock when running on hosted sim
256257
description?: string
257258
tooltip?: string // Tooltip text displayed via info icon next to the title
258259
value?: (params: Record<string, any>) => string

apps/sim/executor/handlers/generic/generic-handler.test.ts

Lines changed: 0 additions & 215 deletions
Original file line numberDiff line numberDiff line change
@@ -147,219 +147,4 @@ describe('GenericBlockHandler', () => {
147147
'Block execution of Some Custom Tool failed with no error message'
148148
)
149149
})
150-
151-
describe('Knowledge block cost tracking', () => {
152-
beforeEach(() => {
153-
// Set up knowledge block mock
154-
mockBlock = {
155-
...mockBlock,
156-
config: { tool: 'knowledge_search', params: {} },
157-
}
158-
159-
mockTool = {
160-
...mockTool,
161-
id: 'knowledge_search',
162-
name: 'Knowledge Search',
163-
}
164-
165-
mockGetTool.mockImplementation((toolId) => {
166-
if (toolId === 'knowledge_search') {
167-
return mockTool
168-
}
169-
return undefined
170-
})
171-
})
172-
173-
it.concurrent(
174-
'should extract and restructure cost information from knowledge tools',
175-
async () => {
176-
const inputs = { query: 'test query' }
177-
const mockToolResponse = {
178-
success: true,
179-
output: {
180-
results: [],
181-
query: 'test query',
182-
totalResults: 0,
183-
cost: {
184-
input: 0.00001042,
185-
output: 0,
186-
total: 0.00001042,
187-
tokens: {
188-
input: 521,
189-
output: 0,
190-
total: 521,
191-
},
192-
model: 'text-embedding-3-small',
193-
pricing: {
194-
input: 0.02,
195-
output: 0,
196-
updatedAt: '2025-07-10',
197-
},
198-
},
199-
},
200-
}
201-
202-
mockExecuteTool.mockResolvedValue(mockToolResponse)
203-
204-
const result = await handler.execute(mockContext, mockBlock, inputs)
205-
206-
// Verify cost information is restructured correctly for enhanced logging
207-
expect(result).toEqual({
208-
results: [],
209-
query: 'test query',
210-
totalResults: 0,
211-
cost: {
212-
input: 0.00001042,
213-
output: 0,
214-
total: 0.00001042,
215-
},
216-
tokens: {
217-
input: 521,
218-
output: 0,
219-
total: 521,
220-
},
221-
model: 'text-embedding-3-small',
222-
})
223-
}
224-
)
225-
226-
it.concurrent('should handle knowledge_upload_chunk cost information', async () => {
227-
// Update to upload_chunk tool
228-
mockBlock.config.tool = 'knowledge_upload_chunk'
229-
mockTool.id = 'knowledge_upload_chunk'
230-
mockTool.name = 'Knowledge Upload Chunk'
231-
232-
mockGetTool.mockImplementation((toolId) => {
233-
if (toolId === 'knowledge_upload_chunk') {
234-
return mockTool
235-
}
236-
return undefined
237-
})
238-
239-
const inputs = { content: 'test content' }
240-
const mockToolResponse = {
241-
success: true,
242-
output: {
243-
data: {
244-
id: 'chunk-123',
245-
content: 'test content',
246-
chunkIndex: 0,
247-
},
248-
message: 'Successfully uploaded chunk',
249-
documentId: 'doc-123',
250-
cost: {
251-
input: 0.00000521,
252-
output: 0,
253-
total: 0.00000521,
254-
tokens: {
255-
input: 260,
256-
output: 0,
257-
total: 260,
258-
},
259-
model: 'text-embedding-3-small',
260-
pricing: {
261-
input: 0.02,
262-
output: 0,
263-
updatedAt: '2025-07-10',
264-
},
265-
},
266-
},
267-
}
268-
269-
mockExecuteTool.mockResolvedValue(mockToolResponse)
270-
271-
const result = await handler.execute(mockContext, mockBlock, inputs)
272-
273-
// Verify cost information is restructured correctly
274-
expect(result).toEqual({
275-
data: {
276-
id: 'chunk-123',
277-
content: 'test content',
278-
chunkIndex: 0,
279-
},
280-
message: 'Successfully uploaded chunk',
281-
documentId: 'doc-123',
282-
cost: {
283-
input: 0.00000521,
284-
output: 0,
285-
total: 0.00000521,
286-
},
287-
tokens: {
288-
input: 260,
289-
output: 0,
290-
total: 260,
291-
},
292-
model: 'text-embedding-3-small',
293-
})
294-
})
295-
296-
it('should pass through output unchanged for knowledge tools without cost info', async () => {
297-
const inputs = { query: 'test query' }
298-
const mockToolResponse = {
299-
success: true,
300-
output: {
301-
results: [],
302-
query: 'test query',
303-
totalResults: 0,
304-
// No cost information
305-
},
306-
}
307-
308-
mockExecuteTool.mockResolvedValue(mockToolResponse)
309-
310-
const result = await handler.execute(mockContext, mockBlock, inputs)
311-
312-
// Should return original output without cost transformation
313-
expect(result).toEqual({
314-
results: [],
315-
query: 'test query',
316-
totalResults: 0,
317-
})
318-
})
319-
320-
it.concurrent(
321-
'should process cost info for all tools (universal cost extraction)',
322-
async () => {
323-
mockBlock.config.tool = 'some_other_tool'
324-
mockTool.id = 'some_other_tool'
325-
326-
mockGetTool.mockImplementation((toolId) => {
327-
if (toolId === 'some_other_tool') {
328-
return mockTool
329-
}
330-
return undefined
331-
})
332-
333-
const inputs = { param: 'value' }
334-
const mockToolResponse = {
335-
success: true,
336-
output: {
337-
result: 'success',
338-
cost: {
339-
input: 0.001,
340-
output: 0.002,
341-
total: 0.003,
342-
tokens: { input: 100, output: 50, total: 150 },
343-
model: 'some-model',
344-
},
345-
},
346-
}
347-
348-
mockExecuteTool.mockResolvedValue(mockToolResponse)
349-
350-
const result = await handler.execute(mockContext, mockBlock, inputs)
351-
352-
expect(result).toEqual({
353-
result: 'success',
354-
cost: {
355-
input: 0.001,
356-
output: 0.002,
357-
total: 0.003,
358-
},
359-
tokens: { input: 100, output: 50, total: 150 },
360-
model: 'some-model',
361-
})
362-
}
363-
)
364-
})
365150
})

apps/sim/executor/handlers/generic/generic-handler.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -98,27 +98,7 @@ export class GenericBlockHandler implements BlockHandler {
9898
throw error
9999
}
100100

101-
const output = result.output
102-
let cost = null
103-
104-
if (output?.cost) {
105-
cost = output.cost
106-
}
107-
108-
if (cost) {
109-
return {
110-
...output,
111-
cost: {
112-
input: cost.input,
113-
output: cost.output,
114-
total: cost.total,
115-
},
116-
tokens: cost.tokens,
117-
model: cost.model,
118-
}
119-
}
120-
121-
return output
101+
return result.output
122102
} catch (error: any) {
123103
if (!error.message || error.message === 'undefined (undefined)') {
124104
let errorMessage = `Block execution of ${tool?.name || block.config.tool} failed`

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { createLogger } from '@sim/logger'
22
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
33
import { API_ENDPOINTS } from '@/stores/constants'
4+
import type { BYOKProviderId } from '@/tools/types'
45

56
const logger = createLogger('BYOKKeysQueries')
67

7-
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral'
8-
98
export interface BYOKKey {
109
id: string
1110
providerId: BYOKProviderId

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ import { isHosted } from '@/lib/core/config/feature-flags'
77
import { decryptSecret } from '@/lib/core/security/encryption'
88
import { getHostedModels } from '@/providers/models'
99
import { useProvidersStore } from '@/stores/providers/store'
10+
import type { BYOKProviderId } from '@/tools/types'
1011

1112
const logger = createLogger('BYOKKeys')
1213

13-
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral'
14-
1514
export interface BYOKKeyResult {
1615
apiKey: string
1716
isBYOK: true

0 commit comments

Comments
 (0)