Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -13,7 +13,7 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per

const logger = createLogger('WorkspaceBYOKKeysAPI')

const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'exa'] as const
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral'] 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,7 +3,6 @@ import {
buildCanonicalIndex,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockHiddenByHostedKey,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
Expand Down Expand Up @@ -109,9 +108,6 @@ export function useEditorSubblockLayout(
// Check required feature if specified - declarative feature gating
if (!isSubBlockFeatureEnabled(block)) return false

// Hide tool API key fields when hosted
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 @@ -16,7 +16,6 @@ import {
evaluateSubBlockCondition,
hasAdvancedValues,
isSubBlockFeatureEnabled,
isSubBlockHiddenByHostedKey,
isSubBlockVisibleForMode,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
Expand Down Expand Up @@ -978,7 +977,6 @@ 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,15 +13,15 @@ import {
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { AnthropicIcon, ExaAIIcon, GeminiIcon, MistralIcon, OpenAIIcon } from '@/components/icons'
import { AnthropicIcon, GeminiIcon, MistralIcon, OpenAIIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import {
type BYOKKey,
type BYOKProviderId,
useBYOKKeys,
useDeleteBYOKKey,
useUpsertBYOKKey,
} from '@/hooks/queries/byok-keys'
import type { BYOKProviderId } from '@/tools/types'

const logger = createLogger('BYOKSettings')

Expand Down Expand Up @@ -60,13 +60,6 @@ const PROVIDERS: {
description: 'LLM calls and Knowledge Base OCR',
placeholder: 'Enter your API key',
},
{
id: 'exa',
name: 'Exa',
icon: ExaAIIcon,
description: 'AI-powered search and research',
placeholder: 'Enter your Exa API key',
},
]

function BYOKKeySkeleton() {
Expand Down
14 changes: 1 addition & 13 deletions apps/sim/blocks/blocks/exa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,26 +309,14 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
value: () => 'exa-research',
condition: { field: 'operation', value: 'exa_research' },
},
// API Key — hidden when hosted for operations with hosted key support
// API Key (common)
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Exa API key',
password: true,
required: true,
hideWhenHosted: true,
condition: { field: 'operation', value: 'exa_research', not: true },
},
// API Key — always visible for research (no hosted key support)
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Exa API key',
password: true,
required: true,
condition: { field: 'operation', value: 'exa_research' },
},
],
tools: {
Expand Down
1 change: 0 additions & 1 deletion apps/sim/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,6 @@ 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
hideWhenHosted?: boolean // Hide this subblock when running on hosted sim
description?: string
tooltip?: string // Tooltip text displayed via info icon next to the title
value?: (params: Record<string, any>) => string
Expand Down
215 changes: 215 additions & 0 deletions apps/sim/executor/handlers/generic/generic-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,219 @@ describe('GenericBlockHandler', () => {
'Block execution of Some Custom Tool failed with no error message'
)
})

describe('Knowledge block cost tracking', () => {
beforeEach(() => {
// Set up knowledge block mock
mockBlock = {
...mockBlock,
config: { tool: 'knowledge_search', params: {} },
}

mockTool = {
...mockTool,
id: 'knowledge_search',
name: 'Knowledge Search',
}

mockGetTool.mockImplementation((toolId) => {
if (toolId === 'knowledge_search') {
return mockTool
}
return undefined
})
})

it.concurrent(
'should extract and restructure cost information from knowledge tools',
async () => {
const inputs = { query: 'test query' }
const mockToolResponse = {
success: true,
output: {
results: [],
query: 'test query',
totalResults: 0,
cost: {
input: 0.00001042,
output: 0,
total: 0.00001042,
tokens: {
input: 521,
output: 0,
total: 521,
},
model: 'text-embedding-3-small',
pricing: {
input: 0.02,
output: 0,
updatedAt: '2025-07-10',
},
},
},
}

mockExecuteTool.mockResolvedValue(mockToolResponse)

const result = await handler.execute(mockContext, mockBlock, inputs)

// Verify cost information is restructured correctly for enhanced logging
expect(result).toEqual({
results: [],
query: 'test query',
totalResults: 0,
cost: {
input: 0.00001042,
output: 0,
total: 0.00001042,
},
tokens: {
input: 521,
output: 0,
total: 521,
},
model: 'text-embedding-3-small',
})
}
)

it.concurrent('should handle knowledge_upload_chunk cost information', async () => {
// Update to upload_chunk tool
mockBlock.config.tool = 'knowledge_upload_chunk'
mockTool.id = 'knowledge_upload_chunk'
mockTool.name = 'Knowledge Upload Chunk'

mockGetTool.mockImplementation((toolId) => {
if (toolId === 'knowledge_upload_chunk') {
return mockTool
}
return undefined
})

const inputs = { content: 'test content' }
const mockToolResponse = {
success: true,
output: {
data: {
id: 'chunk-123',
content: 'test content',
chunkIndex: 0,
},
message: 'Successfully uploaded chunk',
documentId: 'doc-123',
cost: {
input: 0.00000521,
output: 0,
total: 0.00000521,
tokens: {
input: 260,
output: 0,
total: 260,
},
model: 'text-embedding-3-small',
pricing: {
input: 0.02,
output: 0,
updatedAt: '2025-07-10',
},
},
},
}

mockExecuteTool.mockResolvedValue(mockToolResponse)

const result = await handler.execute(mockContext, mockBlock, inputs)

// Verify cost information is restructured correctly
expect(result).toEqual({
data: {
id: 'chunk-123',
content: 'test content',
chunkIndex: 0,
},
message: 'Successfully uploaded chunk',
documentId: 'doc-123',
cost: {
input: 0.00000521,
output: 0,
total: 0.00000521,
},
tokens: {
input: 260,
output: 0,
total: 260,
},
model: 'text-embedding-3-small',
})
})

it('should pass through output unchanged for knowledge tools without cost info', async () => {
const inputs = { query: 'test query' }
const mockToolResponse = {
success: true,
output: {
results: [],
query: 'test query',
totalResults: 0,
// No cost information
},
}

mockExecuteTool.mockResolvedValue(mockToolResponse)

const result = await handler.execute(mockContext, mockBlock, inputs)

// Should return original output without cost transformation
expect(result).toEqual({
results: [],
query: 'test query',
totalResults: 0,
})
})

it.concurrent(
'should process cost info for all tools (universal cost extraction)',
async () => {
mockBlock.config.tool = 'some_other_tool'
mockTool.id = 'some_other_tool'

mockGetTool.mockImplementation((toolId) => {
if (toolId === 'some_other_tool') {
return mockTool
}
return undefined
})

const inputs = { param: 'value' }
const mockToolResponse = {
success: true,
output: {
result: 'success',
cost: {
input: 0.001,
output: 0.002,
total: 0.003,
tokens: { input: 100, output: 50, total: 150 },
model: 'some-model',
},
},
}

mockExecuteTool.mockResolvedValue(mockToolResponse)

const result = await handler.execute(mockContext, mockBlock, inputs)

expect(result).toEqual({
result: 'success',
cost: {
input: 0.001,
output: 0.002,
total: 0.003,
},
tokens: { input: 100, output: 50, total: 150 },
model: 'some-model',
})
}
)
})
})
22 changes: 21 additions & 1 deletion apps/sim/executor/handlers/generic/generic-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,27 @@ export class GenericBlockHandler implements BlockHandler {
throw error
}

return result.output
const output = result.output
let cost = null

if (output?.cost) {
cost = output.cost
}

if (cost) {
return {
...output,
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
tokens: cost.tokens,
model: cost.model,
}
}

return output
} catch (error: any) {
if (!error.message || error.message === 'undefined (undefined)') {
let errorMessage = `Block execution of ${tool?.name || block.config.tool} failed`
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/hooks/queries/byok-keys.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { API_ENDPOINTS } from '@/stores/constants'
import type { BYOKProviderId } from '@/tools/types'

const logger = createLogger('BYOKKeysQueries')

export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated BYOKProviderId type definition. This type is now declared identically in both apps/sim/hooks/queries/byok-keys.ts and apps/sim/lib/api-key/byok.ts. Previously it lived in a single place (tools/types.ts) and was imported where needed. With two independent definitions, a future provider addition would require updating both files — and the two definitions could silently drift apart.

Consider keeping one authoritative declaration (e.g., in lib/api-key/byok.ts) and importing it in this file:

Suggested change
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral'
import type { BYOKProviderId } from '@/lib/api-key/byok'


export interface BYOKKey {
id: string
providerId: BYOKProviderId
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/api-key/byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getHostedModels } from '@/providers/models'
import { useProvidersStore } from '@/stores/providers/store'
import type { BYOKProviderId } from '@/tools/types'

const logger = createLogger('BYOKKeys')

export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral'

export interface BYOKKeyResult {
apiKey: string
isBYOK: true
Expand Down
Loading