mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-14 08:25:03 -05:00
Add custom pricing, switch to exa as first hosted key
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
|
||||
placeholder: 'Enter your Exa API key',
|
||||
password: true,
|
||||
required: true,
|
||||
hideWhenHosted: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
|
||||
@@ -78,7 +78,6 @@ export const SerperBlock: BlockConfig<SearchResponse> = {
|
||||
placeholder: 'Enter your Serper API key',
|
||||
password: true,
|
||||
required: true,
|
||||
hideWhenHosted: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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<void>
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,22 @@ export const answerTool: ToolConfig<ExaAnswerParams, ExaAnswerResponse> = {
|
||||
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<ExaAnswerParams, ExaAnswerResponse> = {
|
||||
url: citation.url,
|
||||
text: citation.text || '',
|
||||
})) || [],
|
||||
costDollars: data.costDollars,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -61,6 +61,22 @@ export const getContentsTool: ToolConfig<ExaGetContentsParams, ExaGetContentsRes
|
||||
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: {
|
||||
url: 'https://api.exa.ai/contents',
|
||||
@@ -132,6 +148,7 @@ export const getContentsTool: ToolConfig<ExaGetContentsParams, ExaGetContentsRes
|
||||
summary: result.summary || '',
|
||||
highlights: result.highlights,
|
||||
})),
|
||||
costDollars: data.costDollars,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -34,6 +34,24 @@ export const researchTool: ToolConfig<ExaResearchParams, ExaResearchResponse> =
|
||||
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<ExaResearchParams, ExaResearchResponse> =
|
||||
score: 1.0,
|
||||
},
|
||||
],
|
||||
// Include cost breakdown for pricing calculation
|
||||
costDollars: taskData.costDollars,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -86,6 +86,28 @@ export const searchTool: ToolConfig<ExaSearchParams, ExaSearchResponse> = {
|
||||
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<ExaSearchParams, ExaSearchResponse> = {
|
||||
highlights: result.highlights,
|
||||
score: result.score,
|
||||
})),
|
||||
costDollars: data.costDollars,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/** Result from cost calculation */
|
||||
interface ToolCostResult {
|
||||
cost: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost based on pricing model
|
||||
*/
|
||||
@@ -153,30 +159,25 @@ function calculateToolCost(
|
||||
pricing: ToolHostingPricing,
|
||||
params: Record<string, unknown>,
|
||||
response: Record<string, unknown>
|
||||
): 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
|
||||
|
||||
@@ -48,15 +48,6 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
|
||||
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'}`,
|
||||
|
||||
@@ -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.
|
||||
* Usage is billed according to the pricing config.
|
||||
*/
|
||||
hosting?: ToolHostingConfig
|
||||
hosting?: ToolHostingConfig<P, R extends ToolResponse ? R : ToolResponse>
|
||||
}
|
||||
|
||||
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<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) */
|
||||
export interface PerSecondPricing {
|
||||
type: 'per_second'
|
||||
@@ -221,14 +199,32 @@ export interface PerSecondPricing {
|
||||
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 */
|
||||
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
|
||||
* 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) */
|
||||
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<P, R>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user