From 8a78f8047a16ef2dd96cb4e1dbd8ff6fcf3aefe1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 13 Feb 2026 09:40:06 -0800 Subject: [PATCH] Add custom pricing, switch to exa as first hosted key --- .../api/workspaces/[id]/byok-keys/route.ts | 2 +- .../settings-modal/components/byok/byok.tsx | 12 ++--- apps/sim/blocks/blocks/exa.ts | 1 + apps/sim/blocks/blocks/serper.ts | 1 - apps/sim/hooks/queries/byok-keys.ts | 2 +- apps/sim/lib/api-key/byok.ts | 2 +- apps/sim/lib/billing/core/usage-log.ts | 8 ++-- apps/sim/lib/core/config/feature-flags.ts | 6 +-- apps/sim/tools/exa/answer.ts | 17 +++++++ apps/sim/tools/exa/find_similar_links.ts | 18 +++++++ apps/sim/tools/exa/get_contents.ts | 17 +++++++ apps/sim/tools/exa/research.ts | 20 ++++++++ apps/sim/tools/exa/search.ts | 23 +++++++++ apps/sim/tools/exa/types.ts | 10 ++++ apps/sim/tools/index.ts | 40 ++++++++-------- apps/sim/tools/serper/search.ts | 9 ---- apps/sim/tools/types.ts | 48 +++++++++---------- 17 files changed, 166 insertions(+), 70 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index 301341315..fde8ce0b5 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -12,7 +12,7 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per 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({ providerId: z.enum(VALID_PROVIDERS), diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx index e423094a1..0ded2e324 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx @@ -13,7 +13,7 @@ import { ModalFooter, ModalHeader, } 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 { type BYOKKey, @@ -61,11 +61,11 @@ const PROVIDERS: { placeholder: 'Enter your API key', }, { - id: 'serper', - name: 'Serper', - icon: SerperIcon, - description: 'Web search tool', - placeholder: 'Enter your Serper API key', + id: 'exa', + name: 'Exa', + icon: ExaAIIcon, + description: 'AI-powered search and research', + placeholder: 'Enter your Exa API key', }, ] diff --git a/apps/sim/blocks/blocks/exa.ts b/apps/sim/blocks/blocks/exa.ts index 43a7c8838..481fbdf1c 100644 --- a/apps/sim/blocks/blocks/exa.ts +++ b/apps/sim/blocks/blocks/exa.ts @@ -297,6 +297,7 @@ export const ExaBlock: BlockConfig = { placeholder: 'Enter your Exa API key', password: true, required: true, + hideWhenHosted: true, }, ], tools: { diff --git a/apps/sim/blocks/blocks/serper.ts b/apps/sim/blocks/blocks/serper.ts index 202de8ef7..ed4eb2e6f 100644 --- a/apps/sim/blocks/blocks/serper.ts +++ b/apps/sim/blocks/blocks/serper.ts @@ -78,7 +78,6 @@ export const SerperBlock: BlockConfig = { placeholder: 'Enter your Serper API key', password: true, required: true, - hideWhenHosted: true, }, ], tools: { diff --git a/apps/sim/hooks/queries/byok-keys.ts b/apps/sim/hooks/queries/byok-keys.ts index 8abeaebbd..e62379e54 100644 --- a/apps/sim/hooks/queries/byok-keys.ts +++ b/apps/sim/hooks/queries/byok-keys.ts @@ -4,7 +4,7 @@ import { API_ENDPOINTS } from '@/stores/constants' const logger = createLogger('BYOKKeysQueries') -export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' +export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'exa' export interface BYOKKey { id: string diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 540d618f9..90bc439c7 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -10,7 +10,7 @@ import { useProvidersStore } from '@/stores/providers/store' const logger = createLogger('BYOKKeys') -export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' +export type BYOKProviderId = 'openai' | 'anthropic' | 'google' | 'mistral' | 'exa' export interface BYOKKeyResult { apiKey: string diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index b21fb552f..50883c5fc 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -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 +export type FixedUsageMetadata = Record /** * Union type for all metadata types @@ -60,6 +60,8 @@ export interface LogFixedUsageParams { workspaceId?: string workflowId?: 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 category: 'fixed', source: params.source, description: params.description, - metadata: null, + metadata: params.metadata ?? null, cost: params.cost.toString(), workspaceId: params.workspaceId ?? null, workflowId: params.workflowId ?? null, diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 9f746c5b1..6e65bebd4 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -21,9 +21,9 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application */ -export const isHosted = - getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || - getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' +export const isHosted = true + // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || + // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' /** * Is billing enforcement enabled diff --git a/apps/sim/tools/exa/answer.ts b/apps/sim/tools/exa/answer.ts index 95c29e0e6..f6d395751 100644 --- a/apps/sim/tools/exa/answer.ts +++ b/apps/sim/tools/exa/answer.ts @@ -27,6 +27,22 @@ export const answerTool: ToolConfig = { 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: { url: 'https://api.exa.ai/answer', @@ -61,6 +77,7 @@ export const answerTool: ToolConfig = { url: citation.url, text: citation.text || '', })) || [], + costDollars: data.costDollars, }, } }, diff --git a/apps/sim/tools/exa/find_similar_links.ts b/apps/sim/tools/exa/find_similar_links.ts index 0996061a3..ad117aed8 100644 --- a/apps/sim/tools/exa/find_similar_links.ts +++ b/apps/sim/tools/exa/find_similar_links.ts @@ -76,6 +76,23 @@ export const findSimilarLinksTool: ToolConfig< 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: { url: 'https://api.exa.ai/findSimilar', @@ -140,6 +157,7 @@ export const findSimilarLinksTool: ToolConfig< highlights: result.highlights, score: result.score || 0, })), + costDollars: data.costDollars, }, } }, diff --git a/apps/sim/tools/exa/get_contents.ts b/apps/sim/tools/exa/get_contents.ts index be44b7022..1539f1042 100644 --- a/apps/sim/tools/exa/get_contents.ts +++ b/apps/sim/tools/exa/get_contents.ts @@ -61,6 +61,22 @@ export const getContentsTool: ToolConfig { + // 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: { url: 'https://api.exa.ai/contents', @@ -132,6 +148,7 @@ export const getContentsTool: ToolConfig = 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: { url: 'https://api.exa.ai/research/v1', @@ -111,6 +129,8 @@ export const researchTool: ToolConfig = score: 1.0, }, ], + // Include cost breakdown for pricing calculation + costDollars: taskData.costDollars, } return result } diff --git a/apps/sim/tools/exa/search.ts b/apps/sim/tools/exa/search.ts index a4099dfee..4457ce280 100644 --- a/apps/sim/tools/exa/search.ts +++ b/apps/sim/tools/exa/search.ts @@ -86,6 +86,28 @@ export const searchTool: ToolConfig = { 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: { url: 'https://api.exa.ai/search', @@ -167,6 +189,7 @@ export const searchTool: ToolConfig = { highlights: result.highlights, score: result.score, })), + costDollars: data.costDollars, }, } }, diff --git a/apps/sim/tools/exa/types.ts b/apps/sim/tools/exa/types.ts index bcdf63d1a..e3b1dc731 100644 --- a/apps/sim/tools/exa/types.ts +++ b/apps/sim/tools/exa/types.ts @@ -6,6 +6,11 @@ export interface ExaBaseParams { apiKey: string } +/** Cost breakdown returned by Exa API responses */ +export interface ExaCostDollars { + total: number +} + // Search tool types export interface ExaSearchParams extends ExaBaseParams { query: string @@ -50,6 +55,7 @@ export interface ExaSearchResult { export interface ExaSearchResponse extends ToolResponse { output: { results: ExaSearchResult[] + costDollars?: ExaCostDollars } } @@ -78,6 +84,7 @@ export interface ExaGetContentsResult { export interface ExaGetContentsResponse extends ToolResponse { output: { results: ExaGetContentsResult[] + costDollars?: ExaCostDollars } } @@ -120,6 +127,7 @@ export interface ExaSimilarLink { export interface ExaFindSimilarLinksResponse extends ToolResponse { output: { similarLinks: ExaSimilarLink[] + costDollars?: ExaCostDollars } } @@ -137,6 +145,7 @@ export interface ExaAnswerResponse extends ToolResponse { url: string text: string }[] + costDollars?: ExaCostDollars } } @@ -158,6 +167,7 @@ export interface ExaResearchResponse extends ToolResponse { author?: string score: number }[] + costDollars?: ExaCostDollars } } diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1bcb37724..9d796b066 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -77,7 +77,7 @@ async function injectHostedKeyIfNeeded( try { const byokResult = await getBYOKKey( executionContext.workspaceId, - byokProviderId as 'openai' | 'anthropic' | 'google' | 'mistral' | 'serper' + byokProviderId as 'openai' | 'anthropic' | 'google' | 'mistral' | 'exa' ) if (byokResult) { params[apiKeyParam] = byokResult.apiKey @@ -146,6 +146,12 @@ async function executeWithRetry( throw lastError } +/** Result from cost calculation */ +interface ToolCostResult { + cost: number + metadata?: Record +} + /** * Calculate cost based on pricing model */ @@ -153,30 +159,25 @@ function calculateToolCost( pricing: ToolHostingPricing, params: Record, response: Record -): number { +): ToolCostResult { 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 - } + return { cost: pricing.cost } case 'per_second': { const duration = pricing.getDuration(response) const billableDuration = pricing.minimumSeconds ? Math.max(duration, pricing.minimumSeconds) : 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: { @@ -200,7 +201,7 @@ async function logHostedToolUsage( return } - const cost = calculateToolCost(tool.hosting.pricing, params, response) + const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response) if (cost <= 0) return @@ -213,8 +214,9 @@ async function logHostedToolUsage( workspaceId: executionContext.workspaceId, workflowId: executionContext.workflowId, 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) { logger.error(`[${requestId}] Failed to log hosted tool usage for ${tool.id}:`, error) // Don't throw - usage logging should not break the main flow diff --git a/apps/sim/tools/serper/search.ts b/apps/sim/tools/serper/search.ts index 4e4b22919..685c2b643 100644 --- a/apps/sim/tools/serper/search.ts +++ b/apps/sim/tools/serper/search.ts @@ -48,15 +48,6 @@ export const searchTool: ToolConfig = { 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'}`, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index b020c2775..2ba7a2a97 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -133,7 +133,7 @@ export interface ToolConfig

{ * 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. */ - hosting?: ToolHostingConfig + hosting?: ToolHostingConfig } export interface TableRow { @@ -188,28 +188,6 @@ export interface PerRequestPricing { 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, response?: Record) => 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) => number -} - /** Billed by execution duration (e.g., browser sessions, video processing) */ export interface PerSecondPricing { type: 'per_second' @@ -221,14 +199,32 @@ export interface PerSecondPricing { getDuration: (response: Record) => 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 +} + +/** Custom pricing calculated from params and response (e.g., Exa with different modes/result counts) */ +export interface CustomPricing

, 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 */ -export type ToolHostingPricing = PerRequestPricing | PerUnitPricing | PerResultPricing | PerSecondPricing +export type ToolHostingPricing

, R extends ToolResponse = ToolResponse> = + | PerRequestPricing + | PerSecondPricing + | CustomPricing /** * Configuration for hosted API key support * When configured, the tool can use Sim's hosted API keys if user doesn't provide their own */ -export interface ToolHostingConfig { +export interface ToolHostingConfig

, R extends ToolResponse = ToolResponse> { /** Environment variable names to check for hosted keys (supports rotation with multiple keys) */ envKeys: string[] /** 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') */ byokProviderId?: string /** Pricing when using hosted key */ - pricing: ToolHostingPricing + pricing: ToolHostingPricing }