Record usage to user stats table

This commit is contained in:
Theodore Li
2026-02-13 11:41:32 -08:00
parent 2a36143f46
commit 36e6464992
4 changed files with 321 additions and 36 deletions

View File

@@ -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) {

View File

@@ -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)
})
})

View File

@@ -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),

View File

@@ -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 {