From 431e84a3429483ace368f28942520cec787c8f44 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 2 Aug 2025 17:43:44 -0700 Subject: [PATCH 1/4] Add copilot billing --- apps/sim/app/api/billing/update-cost/route.ts | 210 ++++++++++++++++++ apps/sim/lib/env.ts | 1 + apps/sim/providers/utils.ts | 6 +- 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/billing/update-cost/route.ts diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts new file mode 100644 index 00000000000..8ef4d4eabd2 --- /dev/null +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -0,0 +1,210 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import crypto from 'crypto' +import { db } from '@/db' +import { userStats } from '@/db/schema' +import { eq, sql } from 'drizzle-orm' +import { env } from '@/lib/env' +import { calculateCost } from '@/providers/utils' +import { createLogger } from '@/lib/logs/console/logger' +import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' +import { isProd } from '@/lib/environment' + +const logger = createLogger('billing-update-cost') + +// Schema for the request body +const UpdateCostSchema = z.object({ + userId: z.string().min(1, 'User ID is required'), + input: z.number().min(0, 'Input tokens must be a non-negative number'), + output: z.number().min(0, 'Output tokens must be a non-negative number'), + model: z.string().min(1, 'Model is required'), +}) + +// Authentication function (reused from copilot/methods route) +function checkInternalApiKey(req: NextRequest) { + const apiKey = req.headers.get('x-api-key') + const expectedApiKey = env.INTERNAL_API_SECRET + + if (!expectedApiKey) { + return { success: false, error: 'Internal API key not configured' } + } + + if (!apiKey) { + return { success: false, error: 'API key required' } + } + + if (apiKey !== expectedApiKey) { + return { success: false, error: 'Invalid API key' } + } + + return { success: true } +} + +/** + * POST /api/billing/update-cost + * Update user cost based on token usage with internal API key auth + */ +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + const startTime = Date.now() + + try { + logger.info(`[${requestId}] Update cost request started`) + + // Check authentication (internal API key) + const authResult = checkInternalApiKey(req) + if (!authResult.success) { + logger.warn(`[${requestId}] Authentication failed: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication failed' + }, + { status: 401 } + ) + } + + // Parse and validate request body + const body = await req.json() + const validation = UpdateCostSchema.safeParse(body) + + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request body`, { + errors: validation.error.issues, + body + }) + return NextResponse.json( + { + success: false, + error: 'Invalid request body', + details: validation.error.issues + }, + { status: 400 } + ) + } + + const { userId, input, output, model } = validation.data + + logger.info(`[${requestId}] Processing cost update`, { + userId, + input, + output, + model + }) + + const finalPromptTokens = input + const finalCompletionTokens = output + const totalTokens = input + output + + // Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions) + const copilotMultiplier = isProd ? (env.COPILOT_COST_MULTIPLIER || 1) : 1 + const costResult = calculateCost(model, finalPromptTokens, finalCompletionTokens, false, copilotMultiplier) + + logger.info(`[${requestId}] Cost calculation result`, { + userId, + model, + promptTokens: finalPromptTokens, + completionTokens: finalCompletionTokens, + totalTokens: totalTokens, + copilotMultiplier, + costResult + }) + + // Follow the exact same logic as ExecutionLogger.updateUserStats but with direct userId + const costToStore = BASE_EXECUTION_CHARGE + costResult.total // No additional multiplier needed since calculateCost already applied it + + // Check if user stats record exists (same as ExecutionLogger) + const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) + + if (userStatsRecords.length === 0) { + // Create new user stats record (same logic as ExecutionLogger) + await db.insert(userStats).values({ + id: crypto.randomUUID(), + userId: userId, + totalManualExecutions: 0, + totalApiCalls: 1, // Count this as an API call + totalWebhookTriggers: 0, + totalScheduledExecutions: 0, + totalChatExecutions: 0, + totalTokensUsed: totalTokens, + totalCost: costToStore.toString(), + currentPeriodCost: costToStore.toString(), + lastActive: new Date(), + }) + + logger.info(`[${requestId}] Created new user stats record`, { + userId, + totalCost: costToStore, + totalTokens + }) + } else { + // Update existing user stats record (same logic as ExecutionLogger) + const updateFields = { + totalTokensUsed: sql`total_tokens_used + ${totalTokens}`, + totalCost: sql`total_cost + ${costToStore}`, + currentPeriodCost: sql`current_period_cost + ${costToStore}`, + totalApiCalls: sql`total_api_calls + 1`, // Increment API calls + lastActive: new Date(), + } + + await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId)) + + logger.info(`[${requestId}] Updated user stats record`, { + userId, + addedCost: costToStore, + addedTokens: totalTokens + }) + } + + const duration = Date.now() - startTime + + logger.info(`[${requestId}] Cost update completed successfully`, { + userId, + duration, + cost: costResult.total, + totalTokens + }) + + return NextResponse.json({ + success: true, + data: { + userId, + input, + output, + totalTokens, + model, + cost: { + input: costResult.input, + output: costResult.output, + total: costResult.total, + }, + tokenBreakdown: { + prompt: finalPromptTokens, + completion: finalCompletionTokens, + total: totalTokens + }, + pricing: costResult.pricing, + processedAt: new Date().toISOString(), + requestId + } + }) + + } catch (error) { + const duration = Date.now() - startTime + + logger.error(`[${requestId}] Cost update failed`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + duration + }) + + return NextResponse.json( + { + success: false, + error: 'Internal server error', + requestId + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index cdc8e5b2e9f..41b40346068 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -67,6 +67,7 @@ export const env = createEnv({ // Monitoring & Analytics TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations + COPILOT_COST_MULTIPLIER: z.number().optional(), // Multiplier for copilot cost calculations SENTRY_ORG: z.string().optional(), // Sentry organization for error tracking SENTRY_PROJECT: z.string().optional(), // Sentry project for error tracking SENTRY_AUTH_TOKEN: z.string().optional(), // Sentry authentication token diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ac1de54b3f0..6e7f759c9ba 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -429,13 +429,15 @@ export async function transformBlockTool( * @param promptTokens Number of prompt tokens used * @param completionTokens Number of completion tokens used * @param useCachedInput Whether to use cached input pricing (default: false) + * @param customMultiplier Optional custom multiplier to override the default cost multiplier * @returns Cost calculation results with input, output and total costs */ export function calculateCost( model: string, promptTokens = 0, completionTokens = 0, - useCachedInput = false + useCachedInput = false, + customMultiplier?: number ) { // First check if it's an embedding model let pricing = getEmbeddingModelPricing(model) @@ -472,7 +474,7 @@ export function calculateCost( const outputCost = completionTokens * (pricing.output / 1_000_000) const totalCost = inputCost + outputCost - const costMultiplier = getCostMultiplier() + const costMultiplier = customMultiplier ?? getCostMultiplier() const finalInputCost = inputCost * costMultiplier const finalOutputCost = outputCost * costMultiplier From 8820cbfa8def8388ac1ac2dc7f82da7e1f7d3df7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 2 Aug 2025 17:44:46 -0700 Subject: [PATCH 2/4] Lint --- apps/sim/app/api/billing/update-cost/route.ts | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 8ef4d4eabd2..3abc62f1c02 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -1,14 +1,14 @@ -import { NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import crypto from 'crypto' -import { db } from '@/db' -import { userStats } from '@/db/schema' import { eq, sql } from 'drizzle-orm' -import { env } from '@/lib/env' -import { calculateCost } from '@/providers/utils' -import { createLogger } from '@/lib/logs/console/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' +import { env } from '@/lib/env' import { isProd } from '@/lib/environment' +import { createLogger } from '@/lib/logs/console/logger' +import { db } from '@/db' +import { userStats } from '@/db/schema' +import { calculateCost } from '@/providers/utils' const logger = createLogger('billing-update-cost') @@ -56,9 +56,9 @@ export async function POST(req: NextRequest) { if (!authResult.success) { logger.warn(`[${requestId}] Authentication failed: ${authResult.error}`) return NextResponse.json( - { - success: false, - error: authResult.error || 'Authentication failed' + { + success: false, + error: authResult.error || 'Authentication failed', }, { status: 401 } ) @@ -67,17 +67,17 @@ export async function POST(req: NextRequest) { // Parse and validate request body const body = await req.json() const validation = UpdateCostSchema.safeParse(body) - + if (!validation.success) { logger.warn(`[${requestId}] Invalid request body`, { errors: validation.error.issues, - body + body, }) return NextResponse.json( { success: false, error: 'Invalid request body', - details: validation.error.issues + details: validation.error.issues, }, { status: 400 } ) @@ -89,7 +89,7 @@ export async function POST(req: NextRequest) { userId, input, output, - model + model, }) const finalPromptTokens = input @@ -97,8 +97,14 @@ export async function POST(req: NextRequest) { const totalTokens = input + output // Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions) - const copilotMultiplier = isProd ? (env.COPILOT_COST_MULTIPLIER || 1) : 1 - const costResult = calculateCost(model, finalPromptTokens, finalCompletionTokens, false, copilotMultiplier) + const copilotMultiplier = isProd ? env.COPILOT_COST_MULTIPLIER || 1 : 1 + const costResult = calculateCost( + model, + finalPromptTokens, + finalCompletionTokens, + false, + copilotMultiplier + ) logger.info(`[${requestId}] Cost calculation result`, { userId, @@ -107,7 +113,7 @@ export async function POST(req: NextRequest) { completionTokens: finalCompletionTokens, totalTokens: totalTokens, copilotMultiplier, - costResult + costResult, }) // Follow the exact same logic as ExecutionLogger.updateUserStats but with direct userId @@ -135,7 +141,7 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Created new user stats record`, { userId, totalCost: costToStore, - totalTokens + totalTokens, }) } else { // Update existing user stats record (same logic as ExecutionLogger) @@ -152,7 +158,7 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Updated user stats record`, { userId, addedCost: costToStore, - addedTokens: totalTokens + addedTokens: totalTokens, }) } @@ -162,7 +168,7 @@ export async function POST(req: NextRequest) { userId, duration, cost: costResult.total, - totalTokens + totalTokens, }) return NextResponse.json({ @@ -181,30 +187,29 @@ export async function POST(req: NextRequest) { tokenBreakdown: { prompt: finalPromptTokens, completion: finalCompletionTokens, - total: totalTokens + total: totalTokens, }, pricing: costResult.pricing, processedAt: new Date().toISOString(), - requestId - } + requestId, + }, }) - } catch (error) { const duration = Date.now() - startTime - + logger.error(`[${requestId}] Cost update failed`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, - duration + duration, }) return NextResponse.json( { success: false, error: 'Internal server error', - requestId + requestId, }, { status: 500 } ) } -} \ No newline at end of file +} From 1e61ea15a93910bbfcbc30a53aa9c71f6da4ffe6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 2 Aug 2025 17:48:09 -0700 Subject: [PATCH 3/4] Update logic --- apps/sim/app/api/billing/update-cost/route.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 3abc62f1c02..fd1ea20e2e2 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -2,7 +2,6 @@ import crypto from 'crypto' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { env } from '@/lib/env' import { isProd } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' @@ -117,7 +116,7 @@ export async function POST(req: NextRequest) { }) // Follow the exact same logic as ExecutionLogger.updateUserStats but with direct userId - const costToStore = BASE_EXECUTION_CHARGE + costResult.total // No additional multiplier needed since calculateCost already applied it + const costToStore = costResult.total // No additional multiplier needed since calculateCost already applied it // Check if user stats record exists (same as ExecutionLogger) const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) @@ -128,7 +127,7 @@ export async function POST(req: NextRequest) { id: crypto.randomUUID(), userId: userId, totalManualExecutions: 0, - totalApiCalls: 1, // Count this as an API call + totalApiCalls: 0, totalWebhookTriggers: 0, totalScheduledExecutions: 0, totalChatExecutions: 0, From acdacba776da88dcf29573242df14da51ee173d3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 2 Aug 2025 17:51:38 -0700 Subject: [PATCH 4/4] Dont count as api callg --- apps/sim/app/api/billing/update-cost/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index fd1ea20e2e2..e31f614cda4 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -148,7 +148,7 @@ export async function POST(req: NextRequest) { totalTokensUsed: sql`total_tokens_used + ${totalTokens}`, totalCost: sql`total_cost + ${costToStore}`, currentPeriodCost: sql`current_period_cost + ${costToStore}`, - totalApiCalls: sql`total_api_calls + 1`, // Increment API calls + totalApiCalls: sql`total_api_calls`, lastActive: new Date(), }