mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-14 08:25:03 -05:00
Record usage to user stats table
This commit is contained in:
@@ -98,10 +98,21 @@ export class GenericBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
const output = result.output
|
||||
let cost = null
|
||||
|
||||
if (output?.cost) {
|
||||
cost = output.cost
|
||||
// Merge costs from output (e.g., AI model costs) and result (e.g., hosted key costs)
|
||||
// TODO: migrate model usage to output cost.
|
||||
const outputCost = output?.cost
|
||||
const resultCost = result.cost
|
||||
|
||||
let cost = null
|
||||
if (outputCost || resultCost) {
|
||||
cost = {
|
||||
input: (outputCost?.input || 0) + (resultCost?.input || 0),
|
||||
output: (outputCost?.output || 0) + (resultCost?.output || 0),
|
||||
total: (outputCost?.total || 0) + (resultCost?.total || 0),
|
||||
tokens: outputCost?.tokens,
|
||||
model: outputCost?.model,
|
||||
}
|
||||
}
|
||||
|
||||
if (cost) {
|
||||
|
||||
@@ -15,6 +15,27 @@ import {
|
||||
} from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock isHosted flag - hoisted so we can control it per test
|
||||
const mockIsHosted = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isHosted: mockIsHosted.value,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
isTest: true,
|
||||
}))
|
||||
|
||||
// Mock getBYOKKey - hoisted so we can control it per test
|
||||
const mockGetBYOKKey = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/api-key/byok', () => ({
|
||||
getBYOKKey: mockGetBYOKKey,
|
||||
}))
|
||||
|
||||
// Mock logFixedUsage for billing
|
||||
const mockLogFixedUsage = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
logFixedUsage: mockLogFixedUsage,
|
||||
}))
|
||||
|
||||
// Mock custom tools query - must be hoisted before imports
|
||||
vi.mock('@/hooks/queries/custom-tools', () => ({
|
||||
getCustomTool: (toolId: string) => {
|
||||
@@ -959,3 +980,233 @@ describe('MCP Tool Execution', () => {
|
||||
expect(result.timing).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hosted Key Injection', () => {
|
||||
let cleanupEnvVars: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
|
||||
vi.clearAllMocks()
|
||||
mockGetBYOKKey.mockReset()
|
||||
mockLogFixedUsage.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
cleanupEnvVars()
|
||||
})
|
||||
|
||||
it('should not inject hosted key when tool has no hosting config', async () => {
|
||||
const mockTool = {
|
||||
id: 'test_no_hosting',
|
||||
name: 'Test No Hosting',
|
||||
description: 'A test tool without hosting config',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: '/api/test/endpoint',
|
||||
method: 'POST' as const,
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'success' },
|
||||
}),
|
||||
}
|
||||
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_no_hosting = mockTool
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const mockContext = createToolExecutionContext()
|
||||
await executeTool('test_no_hosting', {}, false, mockContext)
|
||||
|
||||
// BYOK should not be called since there's no hosting config
|
||||
expect(mockGetBYOKKey).not.toHaveBeenCalled()
|
||||
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it('should check BYOK key first when tool has hosting config', async () => {
|
||||
// Note: isHosted is mocked to false by default, so hosted key injection won't happen
|
||||
// This test verifies the flow when isHosted would be true
|
||||
const mockTool = {
|
||||
id: 'test_with_hosting',
|
||||
name: 'Test With Hosting',
|
||||
description: 'A test tool with hosting config',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
apiKey: { type: 'string', required: true },
|
||||
},
|
||||
hosting: {
|
||||
envKeys: ['TEST_API_KEY'],
|
||||
apiKeyParam: 'apiKey',
|
||||
byokProviderId: 'exa',
|
||||
pricing: {
|
||||
type: 'per_request' as const,
|
||||
cost: 0.005,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: '/api/test/endpoint',
|
||||
method: 'POST' as const,
|
||||
headers: (params: any) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': params.apiKey,
|
||||
}),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'success' },
|
||||
}),
|
||||
}
|
||||
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_with_hosting = mockTool
|
||||
|
||||
// Mock BYOK returning a key
|
||||
mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-test-key', isBYOK: true })
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const mockContext = createToolExecutionContext()
|
||||
await executeTool('test_with_hosting', {}, false, mockContext)
|
||||
|
||||
// With isHosted=false, BYOK won't be called - this is expected behavior
|
||||
// The test documents the current behavior
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it('should use per_request pricing model correctly', async () => {
|
||||
const mockTool = {
|
||||
id: 'test_per_request_pricing',
|
||||
name: 'Test Per Request Pricing',
|
||||
description: 'A test tool with per_request pricing',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
apiKey: { type: 'string', required: true },
|
||||
},
|
||||
hosting: {
|
||||
envKeys: ['TEST_API_KEY'],
|
||||
apiKeyParam: 'apiKey',
|
||||
byokProviderId: 'exa',
|
||||
pricing: {
|
||||
type: 'per_request' as const,
|
||||
cost: 0.005,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: '/api/test/endpoint',
|
||||
method: 'POST' as const,
|
||||
headers: (params: any) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': params.apiKey,
|
||||
}),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'success' },
|
||||
}),
|
||||
}
|
||||
|
||||
// Verify pricing config structure
|
||||
expect(mockTool.hosting.pricing.type).toBe('per_request')
|
||||
expect(mockTool.hosting.pricing.cost).toBe(0.005)
|
||||
})
|
||||
|
||||
it('should use custom pricing model correctly', async () => {
|
||||
const mockGetCost = vi.fn().mockReturnValue({ cost: 0.01, metadata: { breakdown: 'test' } })
|
||||
|
||||
const mockTool = {
|
||||
id: 'test_custom_pricing',
|
||||
name: 'Test Custom Pricing',
|
||||
description: 'A test tool with custom pricing',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
apiKey: { type: 'string', required: true },
|
||||
},
|
||||
hosting: {
|
||||
envKeys: ['TEST_API_KEY'],
|
||||
apiKeyParam: 'apiKey',
|
||||
byokProviderId: 'exa',
|
||||
pricing: {
|
||||
type: 'custom' as const,
|
||||
getCost: mockGetCost,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: '/api/test/endpoint',
|
||||
method: 'POST' as const,
|
||||
headers: (params: any) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': params.apiKey,
|
||||
}),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'success', costDollars: { total: 0.01 } },
|
||||
}),
|
||||
}
|
||||
|
||||
// Verify pricing config structure
|
||||
expect(mockTool.hosting.pricing.type).toBe('custom')
|
||||
expect(typeof mockTool.hosting.pricing.getCost).toBe('function')
|
||||
|
||||
// Test getCost returns expected value
|
||||
const result = mockTool.hosting.pricing.getCost({}, { costDollars: { total: 0.01 } })
|
||||
expect(result).toEqual({ cost: 0.01, metadata: { breakdown: 'test' } })
|
||||
})
|
||||
|
||||
it('should handle custom pricing returning a number', async () => {
|
||||
const mockGetCost = vi.fn().mockReturnValue(0.005)
|
||||
|
||||
const mockTool = {
|
||||
id: 'test_custom_pricing_number',
|
||||
name: 'Test Custom Pricing Number',
|
||||
description: 'A test tool with custom pricing returning number',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
apiKey: { type: 'string', required: true },
|
||||
},
|
||||
hosting: {
|
||||
envKeys: ['TEST_API_KEY'],
|
||||
apiKeyParam: 'apiKey',
|
||||
byokProviderId: 'exa',
|
||||
pricing: {
|
||||
type: 'custom' as const,
|
||||
getCost: mockGetCost,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: '/api/test/endpoint',
|
||||
method: 'POST' as const,
|
||||
headers: (params: any) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': params.apiKey,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
// Test getCost returns a number
|
||||
const result = mockTool.hosting.pricing.getCost({}, {})
|
||||
expect(result).toBe(0.005)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -210,39 +210,44 @@ function calculateToolCost(
|
||||
}
|
||||
|
||||
/**
|
||||
* Log usage for a tool that used a hosted API key
|
||||
* Calculate and log hosted key cost for a tool execution.
|
||||
* Logs to usageLog for audit trail and returns cost for accumulation in userStats.
|
||||
*/
|
||||
async function logHostedToolUsage(
|
||||
async function processHostedKeyCost(
|
||||
tool: ToolConfig,
|
||||
params: Record<string, unknown>,
|
||||
response: Record<string, unknown>,
|
||||
executionContext: ExecutionContext | undefined,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
if (!tool.hosting?.pricing || !executionContext?.userId) {
|
||||
return
|
||||
): Promise<number> {
|
||||
if (!tool.hosting?.pricing) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response)
|
||||
|
||||
if (cost <= 0) return
|
||||
if (cost <= 0) return 0
|
||||
|
||||
try {
|
||||
await logFixedUsage({
|
||||
userId: executionContext.userId,
|
||||
source: 'workflow',
|
||||
description: `tool:${tool.id}`,
|
||||
cost,
|
||||
workspaceId: executionContext.workspaceId,
|
||||
workflowId: executionContext.workflowId,
|
||||
executionId: executionContext.executionId,
|
||||
metadata,
|
||||
})
|
||||
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
|
||||
// Log to usageLog table for audit trail
|
||||
if (executionContext?.userId) {
|
||||
try {
|
||||
await logFixedUsage({
|
||||
userId: executionContext.userId,
|
||||
source: 'workflow',
|
||||
description: `tool:${tool.id}`,
|
||||
cost,
|
||||
workspaceId: executionContext.workspaceId,
|
||||
workflowId: executionContext.workflowId,
|
||||
executionId: executionContext.executionId,
|
||||
metadata,
|
||||
})
|
||||
logger.debug(`[${requestId}] Logged hosted key cost for ${tool.id}: $${cost}`, metadata ? { metadata } : {})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to log hosted key usage for ${tool.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return cost
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -617,16 +622,18 @@ export async function executeTool(
|
||||
// Process file outputs if execution context is available
|
||||
finalResult = await processFileOutputs(finalResult, tool, executionContext)
|
||||
|
||||
// Log usage for hosted key if execution was successful
|
||||
if (hostedKeyInfo.isUsingHostedKey && finalResult.success) {
|
||||
await logHostedToolUsage(tool, contextParams, finalResult.output, executionContext, requestId)
|
||||
}
|
||||
|
||||
// Add timing data to the result
|
||||
const endTime = new Date()
|
||||
const endTimeISO = endTime.toISOString()
|
||||
const duration = endTime.getTime() - startTime.getTime()
|
||||
return {
|
||||
|
||||
// Calculate and log hosted key cost if applicable
|
||||
let hostedKeyCost = 0
|
||||
if (hostedKeyInfo.isUsingHostedKey && finalResult.success) {
|
||||
hostedKeyCost = await processHostedKeyCost(tool, contextParams, finalResult.output, executionContext, requestId)
|
||||
}
|
||||
|
||||
const response: ToolResponse = {
|
||||
...finalResult,
|
||||
timing: {
|
||||
startTime: startTimeISO,
|
||||
@@ -634,6 +641,10 @@ export async function executeTool(
|
||||
duration,
|
||||
},
|
||||
}
|
||||
if (hostedKeyCost > 0) {
|
||||
response.cost = { total: hostedKeyCost }
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// Execute the tool request directly (internal routes use regular fetch, external use SSRF-protected fetch)
|
||||
@@ -666,16 +677,18 @@ export async function executeTool(
|
||||
// Process file outputs if execution context is available
|
||||
finalResult = await processFileOutputs(finalResult, tool, executionContext)
|
||||
|
||||
// Log usage for hosted key if execution was successful
|
||||
if (hostedKeyInfo.isUsingHostedKey && finalResult.success) {
|
||||
await logHostedToolUsage(tool, contextParams, finalResult.output, executionContext, requestId)
|
||||
}
|
||||
|
||||
// Add timing data to the result
|
||||
const endTime = new Date()
|
||||
const endTimeISO = endTime.toISOString()
|
||||
const duration = endTime.getTime() - startTime.getTime()
|
||||
return {
|
||||
|
||||
// Calculate and log hosted key cost if applicable
|
||||
let hostedKeyCost = 0
|
||||
if (hostedKeyInfo.isUsingHostedKey && finalResult.success) {
|
||||
hostedKeyCost = await processHostedKeyCost(tool, contextParams, finalResult.output, executionContext, requestId)
|
||||
}
|
||||
|
||||
const response: ToolResponse = {
|
||||
...finalResult,
|
||||
timing: {
|
||||
startTime: startTimeISO,
|
||||
@@ -683,6 +696,10 @@ export async function executeTool(
|
||||
duration,
|
||||
},
|
||||
}
|
||||
if (hostedKeyCost > 0) {
|
||||
response.cost = { total: hostedKeyCost }
|
||||
}
|
||||
return response
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error executing tool ${toolId}:`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -41,6 +41,12 @@ export interface ToolResponse {
|
||||
endTime: string // ISO timestamp when the tool execution ended
|
||||
duration: number // Duration in milliseconds
|
||||
}
|
||||
// Cost incurred by this tool execution (for billing)
|
||||
cost?: {
|
||||
total: number
|
||||
input?: number
|
||||
output?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface OAuthConfig {
|
||||
|
||||
Reference in New Issue
Block a user