Add custom pricing, switch to exa as first hosted key

This commit is contained in:
Theodore Li
2026-02-13 09:40:06 -08:00
parent e5c8aec07d
commit 8a78f8047a
17 changed files with 166 additions and 70 deletions

View File

@@ -12,7 +12,7 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per
const logger = createLogger('WorkspaceBYOKKeysAPI') const logger = createLogger('WorkspaceBYOKKeysAPI')
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'serper'] as const const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'serper', 'exa'] as const
const UpsertKeySchema = z.object({ const UpsertKeySchema = z.object({
providerId: z.enum(VALID_PROVIDERS), providerId: z.enum(VALID_PROVIDERS),

View File

@@ -13,7 +13,7 @@ import {
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
} from '@/components/emcn' } from '@/components/emcn'
import { AnthropicIcon, GeminiIcon, MistralIcon, OpenAIIcon, SerperIcon } from '@/components/icons' import { AnthropicIcon, ExaAIIcon, GeminiIcon, MistralIcon, OpenAIIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui' import { Skeleton } from '@/components/ui'
import { import {
type BYOKKey, type BYOKKey,
@@ -61,11 +61,11 @@ const PROVIDERS: {
placeholder: 'Enter your API key', placeholder: 'Enter your API key',
}, },
{ {
id: 'serper', id: 'exa',
name: 'Serper', name: 'Exa',
icon: SerperIcon, icon: ExaAIIcon,
description: 'Web search tool', description: 'AI-powered search and research',
placeholder: 'Enter your Serper API key', placeholder: 'Enter your Exa API key',
}, },
] ]

View File

@@ -297,6 +297,7 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
placeholder: 'Enter your Exa API key', placeholder: 'Enter your Exa API key',
password: true, password: true,
required: true, required: true,
hideWhenHosted: true,
}, },
], ],
tools: { tools: {

View File

@@ -78,7 +78,6 @@ export const SerperBlock: BlockConfig<SearchResponse> = {
placeholder: 'Enter your Serper API key', placeholder: 'Enter your Serper API key',
password: true, password: true,
required: true, required: true,
hideWhenHosted: true,
}, },
], ],
tools: { tools: {

View File

@@ -4,7 +4,7 @@ import { API_ENDPOINTS } from '@/stores/constants'
const logger = createLogger('BYOKKeysQueries') const logger = createLogger('BYOKKeysQueries')
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'exa'
export interface BYOKKey { export interface BYOKKey {
id: string id: string

View File

@@ -10,7 +10,7 @@ import { useProvidersStore } from '@/stores/providers/store'
const logger = createLogger('BYOKKeys') const logger = createLogger('BYOKKeys')
export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'exa'
export interface BYOKKeyResult { export interface BYOKKeyResult {
apiKey: string apiKey: string

View File

@@ -25,9 +25,9 @@ export interface ModelUsageMetadata {
} }
/** /**
* Metadata for 'fixed' category charges (currently empty, extensible) * Metadata for 'fixed' category charges (e.g., tool cost breakdown)
*/ */
export type FixedUsageMetadata = Record<string, never> export type FixedUsageMetadata = Record<string, unknown>
/** /**
* Union type for all metadata types * Union type for all metadata types
@@ -60,6 +60,8 @@ export interface LogFixedUsageParams {
workspaceId?: string workspaceId?: string
workflowId?: string workflowId?: string
executionId?: string executionId?: string
/** Optional metadata (e.g., tool cost breakdown from API) */
metadata?: FixedUsageMetadata
} }
/** /**
@@ -119,7 +121,7 @@ export async function logFixedUsage(params: LogFixedUsageParams): Promise<void>
category: 'fixed', category: 'fixed',
source: params.source, source: params.source,
description: params.description, description: params.description,
metadata: null, metadata: params.metadata ?? null,
cost: params.cost.toString(), cost: params.cost.toString(),
workspaceId: params.workspaceId ?? null, workspaceId: params.workspaceId ?? null,
workflowId: params.workflowId ?? null, workflowId: params.workflowId ?? null,

View File

@@ -21,9 +21,9 @@ export const isTest = env.NODE_ENV === 'test'
/** /**
* Is this the hosted version of the application * Is this the hosted version of the application
*/ */
export const isHosted = export const isHosted = true
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
/** /**
* Is billing enforcement enabled * Is billing enforcement enabled

View File

@@ -27,6 +27,22 @@ export const answerTool: ToolConfig<ExaAnswerParams, ExaAnswerResponse> = {
description: 'Exa AI API Key', description: 'Exa AI API Key',
}, },
}, },
hosting: {
envKeys: ['EXA_API_KEY'],
apiKeyParam: 'apiKey',
byokProviderId: 'exa',
pricing: {
type: 'custom',
getCost: (_params, response) => {
// Use costDollars from Exa API response
if (response.costDollars?.total) {
return { cost: response.costDollars.total, metadata: { costDollars: response.costDollars } }
}
// Fallback: $5/1000 requests
return 0.005
},
},
},
request: { request: {
url: 'https://api.exa.ai/answer', url: 'https://api.exa.ai/answer',
@@ -61,6 +77,7 @@ export const answerTool: ToolConfig<ExaAnswerParams, ExaAnswerResponse> = {
url: citation.url, url: citation.url,
text: citation.text || '', text: citation.text || '',
})) || [], })) || [],
costDollars: data.costDollars,
}, },
} }
}, },

View File

@@ -76,6 +76,23 @@ export const findSimilarLinksTool: ToolConfig<
description: 'Exa AI API Key', description: 'Exa AI API Key',
}, },
}, },
hosting: {
envKeys: ['EXA_API_KEY'],
apiKeyParam: 'apiKey',
byokProviderId: 'exa',
pricing: {
type: 'custom',
getCost: (_params, response) => {
// Use costDollars from Exa API response
if (response.costDollars?.total) {
return { cost: response.costDollars.total, metadata: { costDollars: response.costDollars } }
}
// Fallback: $5/1000 (1-25 results) or $25/1000 (26-100 results)
const resultCount = response.similarLinks?.length || 0
return resultCount <= 25 ? 0.005 : 0.025
},
},
},
request: { request: {
url: 'https://api.exa.ai/findSimilar', url: 'https://api.exa.ai/findSimilar',
@@ -140,6 +157,7 @@ export const findSimilarLinksTool: ToolConfig<
highlights: result.highlights, highlights: result.highlights,
score: result.score || 0, score: result.score || 0,
})), })),
costDollars: data.costDollars,
}, },
} }
}, },

View File

@@ -61,6 +61,22 @@ export const getContentsTool: ToolConfig<ExaGetContentsParams, ExaGetContentsRes
description: 'Exa AI API Key', description: 'Exa AI API Key',
}, },
}, },
hosting: {
envKeys: ['EXA_API_KEY'],
apiKeyParam: 'apiKey',
byokProviderId: 'exa',
pricing: {
type: 'custom',
getCost: (_params, response) => {
// Use costDollars from Exa API response
if (response.costDollars?.total) {
return { cost: response.costDollars.total, metadata: { costDollars: response.costDollars } }
}
// Fallback: $1/1000 pages
return (response.results?.length || 0) * 0.001
},
},
},
request: { request: {
url: 'https://api.exa.ai/contents', url: 'https://api.exa.ai/contents',
@@ -132,6 +148,7 @@ export const getContentsTool: ToolConfig<ExaGetContentsParams, ExaGetContentsRes
summary: result.summary || '', summary: result.summary || '',
highlights: result.highlights, highlights: result.highlights,
})), })),
costDollars: data.costDollars,
}, },
} }
}, },

View File

@@ -34,6 +34,24 @@ export const researchTool: ToolConfig<ExaResearchParams, ExaResearchResponse> =
description: 'Exa AI API Key', description: 'Exa AI API Key',
}, },
}, },
hosting: {
envKeys: ['EXA_API_KEY'],
apiKeyParam: 'apiKey',
byokProviderId: 'exa',
pricing: {
type: 'custom',
getCost: (params, response) => {
// Use costDollars from Exa API response
if (response.costDollars?.total) {
return { cost: response.costDollars.total, metadata: { costDollars: response.costDollars } }
}
// Fallback to estimate if cost not available
const model = params.model || 'exa-research'
return model === 'exa-research-pro' ? 0.055 : 0.03
},
},
},
request: { request: {
url: 'https://api.exa.ai/research/v1', url: 'https://api.exa.ai/research/v1',
@@ -111,6 +129,8 @@ export const researchTool: ToolConfig<ExaResearchParams, ExaResearchResponse> =
score: 1.0, score: 1.0,
}, },
], ],
// Include cost breakdown for pricing calculation
costDollars: taskData.costDollars,
} }
return result return result
} }

View File

@@ -86,6 +86,28 @@ export const searchTool: ToolConfig<ExaSearchParams, ExaSearchResponse> = {
description: 'Exa AI API Key', description: 'Exa AI API Key',
}, },
}, },
hosting: {
envKeys: ['EXA_API_KEY'],
apiKeyParam: 'apiKey',
byokProviderId: 'exa',
pricing: {
type: 'custom',
getCost: (params, response) => {
// Use costDollars from Exa API response
if (response.costDollars?.total) {
return { cost: response.costDollars.total, metadata: { costDollars: response.costDollars } }
}
// Fallback: estimate based on search type and result count
const isDeepSearch = params.type === 'neural'
if (isDeepSearch) {
return 0.015
}
const resultCount = response.results?.length || 0
return resultCount <= 25 ? 0.005 : 0.025
},
},
},
request: { request: {
url: 'https://api.exa.ai/search', url: 'https://api.exa.ai/search',
@@ -167,6 +189,7 @@ export const searchTool: ToolConfig<ExaSearchParams, ExaSearchResponse> = {
highlights: result.highlights, highlights: result.highlights,
score: result.score, score: result.score,
})), })),
costDollars: data.costDollars,
}, },
} }
}, },

View File

@@ -6,6 +6,11 @@ export interface ExaBaseParams {
apiKey: string apiKey: string
} }
/** Cost breakdown returned by Exa API responses */
export interface ExaCostDollars {
total: number
}
// Search tool types // Search tool types
export interface ExaSearchParams extends ExaBaseParams { export interface ExaSearchParams extends ExaBaseParams {
query: string query: string
@@ -50,6 +55,7 @@ export interface ExaSearchResult {
export interface ExaSearchResponse extends ToolResponse { export interface ExaSearchResponse extends ToolResponse {
output: { output: {
results: ExaSearchResult[] results: ExaSearchResult[]
costDollars?: ExaCostDollars
} }
} }
@@ -78,6 +84,7 @@ export interface ExaGetContentsResult {
export interface ExaGetContentsResponse extends ToolResponse { export interface ExaGetContentsResponse extends ToolResponse {
output: { output: {
results: ExaGetContentsResult[] results: ExaGetContentsResult[]
costDollars?: ExaCostDollars
} }
} }
@@ -120,6 +127,7 @@ export interface ExaSimilarLink {
export interface ExaFindSimilarLinksResponse extends ToolResponse { export interface ExaFindSimilarLinksResponse extends ToolResponse {
output: { output: {
similarLinks: ExaSimilarLink[] similarLinks: ExaSimilarLink[]
costDollars?: ExaCostDollars
} }
} }
@@ -137,6 +145,7 @@ export interface ExaAnswerResponse extends ToolResponse {
url: string url: string
text: string text: string
}[] }[]
costDollars?: ExaCostDollars
} }
} }
@@ -158,6 +167,7 @@ export interface ExaResearchResponse extends ToolResponse {
author?: string author?: string
score: number score: number
}[] }[]
costDollars?: ExaCostDollars
} }
} }

View File

@@ -77,7 +77,7 @@ async function injectHostedKeyIfNeeded(
try { try {
const byokResult = await getBYOKKey( const byokResult = await getBYOKKey(
executionContext.workspaceId, executionContext.workspaceId,
byokProviderId as 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' byokProviderId as 'openai' | 'anthropic' | 'google' | 'mistral' | 'exa'
) )
if (byokResult) { if (byokResult) {
params[apiKeyParam] = byokResult.apiKey params[apiKeyParam] = byokResult.apiKey
@@ -146,6 +146,12 @@ async function executeWithRetry<T>(
throw lastError throw lastError
} }
/** Result from cost calculation */
interface ToolCostResult {
cost: number
metadata?: Record<string, unknown>
}
/** /**
* Calculate cost based on pricing model * Calculate cost based on pricing model
*/ */
@@ -153,30 +159,25 @@ function calculateToolCost(
pricing: ToolHostingPricing, pricing: ToolHostingPricing,
params: Record<string, unknown>, params: Record<string, unknown>,
response: Record<string, unknown> response: Record<string, unknown>
): number { ): ToolCostResult {
switch (pricing.type) { switch (pricing.type) {
case 'per_request': case 'per_request':
return pricing.cost return { cost: 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': { case 'per_second': {
const duration = pricing.getDuration(response) const duration = pricing.getDuration(response)
const billableDuration = pricing.minimumSeconds const billableDuration = pricing.minimumSeconds
? Math.max(duration, pricing.minimumSeconds) ? Math.max(duration, pricing.minimumSeconds)
: duration : duration
return billableDuration * pricing.costPerSecond return { cost: billableDuration * pricing.costPerSecond }
}
case 'custom': {
const result = pricing.getCost(params, response)
if (typeof result === 'number') {
return { cost: result }
}
return result
} }
default: { default: {
@@ -200,7 +201,7 @@ async function logHostedToolUsage(
return return
} }
const cost = calculateToolCost(tool.hosting.pricing, params, response) const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response)
if (cost <= 0) return if (cost <= 0) return
@@ -213,8 +214,9 @@ async function logHostedToolUsage(
workspaceId: executionContext.workspaceId, workspaceId: executionContext.workspaceId,
workflowId: executionContext.workflowId, workflowId: executionContext.workflowId,
executionId: executionContext.executionId, executionId: executionContext.executionId,
metadata,
}) })
logger.debug(`[${requestId}] Logged hosted tool usage for ${tool.id}: $${cost}`) logger.debug(`[${requestId}] Logged hosted tool usage for ${tool.id}: $${cost}`, metadata ? { metadata } : {})
} catch (error) { } catch (error) {
logger.error(`[${requestId}] Failed to log hosted tool usage for ${tool.id}:`, 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 // Don't throw - usage logging should not break the main flow

View File

@@ -48,15 +48,6 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
description: 'Serper API Key', 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: { request: {
url: (params) => `https://google.serper.dev/${params.type || 'search'}`, url: (params) => `https://google.serper.dev/${params.type || 'search'}`,

View File

@@ -133,7 +133,7 @@ export interface ToolConfig<P = any, R = any> {
* When configured, the tool can use Sim's hosted API keys if user doesn't provide their own. * 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. * Usage is billed according to the pricing config.
*/ */
hosting?: ToolHostingConfig hosting?: ToolHostingConfig<P, R extends ToolResponse ? R : ToolResponse>
} }
export interface TableRow { export interface TableRow {
@@ -188,28 +188,6 @@ export interface PerRequestPricing {
cost: number 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<string, unknown>, response?: Record<string, unknown>) => 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<string, unknown>) => number
}
/** Billed by execution duration (e.g., browser sessions, video processing) */ /** Billed by execution duration (e.g., browser sessions, video processing) */
export interface PerSecondPricing { export interface PerSecondPricing {
type: 'per_second' type: 'per_second'
@@ -221,14 +199,32 @@ export interface PerSecondPricing {
getDuration: (response: Record<string, unknown>) => number getDuration: (response: Record<string, unknown>) => number
} }
/** Result from custom pricing calculation */
export interface CustomPricingResult {
/** Cost in dollars */
cost: number
/** Optional metadata about the cost calculation (e.g., breakdown from API) */
metadata?: Record<string, unknown>
}
/** Custom pricing calculated from params and response (e.g., Exa with different modes/result counts) */
export interface CustomPricing<P = Record<string, unknown>, R extends ToolResponse = ToolResponse> {
type: 'custom'
/** Calculate cost based on request params and response data. Returns cost or cost with metadata. */
getCost: (params: P, response: R['output']) => number | CustomPricingResult
}
/** Union of all pricing models */ /** Union of all pricing models */
export type ToolHostingPricing = PerRequestPricing | PerUnitPricing | PerResultPricing | PerSecondPricing export type ToolHostingPricing<P = Record<string, unknown>, R extends ToolResponse = ToolResponse> =
| PerRequestPricing
| PerSecondPricing
| CustomPricing<P, R>
/** /**
* Configuration for hosted API key support * Configuration for hosted API key support
* When configured, the tool can use Sim's hosted API keys if user doesn't provide their own * When configured, the tool can use Sim's hosted API keys if user doesn't provide their own
*/ */
export interface ToolHostingConfig { export interface ToolHostingConfig<P = Record<string, unknown>, R extends ToolResponse = ToolResponse> {
/** Environment variable names to check for hosted keys (supports rotation with multiple keys) */ /** Environment variable names to check for hosted keys (supports rotation with multiple keys) */
envKeys: string[] envKeys: string[]
/** The parameter name that receives the API key */ /** The parameter name that receives the API key */
@@ -236,5 +232,5 @@ export interface ToolHostingConfig {
/** BYOK provider ID for workspace key lookup (e.g., 'serper') */ /** BYOK provider ID for workspace key lookup (e.g., 'serper') */
byokProviderId?: string byokProviderId?: string
/** Pricing when using hosted key */ /** Pricing when using hosted key */
pricing: ToolHostingPricing pricing: ToolHostingPricing<P, R>
} }