Skip to content

Commit 6c12104

Browse files
authored
feat(copilot): add billing endpoint (#855)
* Add copilot billing * Lint * Update logic * Dont count as api callg
1 parent 9f0673b commit 6c12104

File tree

3 files changed

+219
-2
lines changed

3 files changed

+219
-2
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import crypto from 'crypto'
2+
import { eq, sql } from 'drizzle-orm'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { z } from 'zod'
5+
import { env } from '@/lib/env'
6+
import { isProd } from '@/lib/environment'
7+
import { createLogger } from '@/lib/logs/console/logger'
8+
import { db } from '@/db'
9+
import { userStats } from '@/db/schema'
10+
import { calculateCost } from '@/providers/utils'
11+
12+
const logger = createLogger('billing-update-cost')
13+
14+
// Schema for the request body
15+
const UpdateCostSchema = z.object({
16+
userId: z.string().min(1, 'User ID is required'),
17+
input: z.number().min(0, 'Input tokens must be a non-negative number'),
18+
output: z.number().min(0, 'Output tokens must be a non-negative number'),
19+
model: z.string().min(1, 'Model is required'),
20+
})
21+
22+
// Authentication function (reused from copilot/methods route)
23+
function checkInternalApiKey(req: NextRequest) {
24+
const apiKey = req.headers.get('x-api-key')
25+
const expectedApiKey = env.INTERNAL_API_SECRET
26+
27+
if (!expectedApiKey) {
28+
return { success: false, error: 'Internal API key not configured' }
29+
}
30+
31+
if (!apiKey) {
32+
return { success: false, error: 'API key required' }
33+
}
34+
35+
if (apiKey !== expectedApiKey) {
36+
return { success: false, error: 'Invalid API key' }
37+
}
38+
39+
return { success: true }
40+
}
41+
42+
/**
43+
* POST /api/billing/update-cost
44+
* Update user cost based on token usage with internal API key auth
45+
*/
46+
export async function POST(req: NextRequest) {
47+
const requestId = crypto.randomUUID().slice(0, 8)
48+
const startTime = Date.now()
49+
50+
try {
51+
logger.info(`[${requestId}] Update cost request started`)
52+
53+
// Check authentication (internal API key)
54+
const authResult = checkInternalApiKey(req)
55+
if (!authResult.success) {
56+
logger.warn(`[${requestId}] Authentication failed: ${authResult.error}`)
57+
return NextResponse.json(
58+
{
59+
success: false,
60+
error: authResult.error || 'Authentication failed',
61+
},
62+
{ status: 401 }
63+
)
64+
}
65+
66+
// Parse and validate request body
67+
const body = await req.json()
68+
const validation = UpdateCostSchema.safeParse(body)
69+
70+
if (!validation.success) {
71+
logger.warn(`[${requestId}] Invalid request body`, {
72+
errors: validation.error.issues,
73+
body,
74+
})
75+
return NextResponse.json(
76+
{
77+
success: false,
78+
error: 'Invalid request body',
79+
details: validation.error.issues,
80+
},
81+
{ status: 400 }
82+
)
83+
}
84+
85+
const { userId, input, output, model } = validation.data
86+
87+
logger.info(`[${requestId}] Processing cost update`, {
88+
userId,
89+
input,
90+
output,
91+
model,
92+
})
93+
94+
const finalPromptTokens = input
95+
const finalCompletionTokens = output
96+
const totalTokens = input + output
97+
98+
// Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions)
99+
const copilotMultiplier = isProd ? env.COPILOT_COST_MULTIPLIER || 1 : 1
100+
const costResult = calculateCost(
101+
model,
102+
finalPromptTokens,
103+
finalCompletionTokens,
104+
false,
105+
copilotMultiplier
106+
)
107+
108+
logger.info(`[${requestId}] Cost calculation result`, {
109+
userId,
110+
model,
111+
promptTokens: finalPromptTokens,
112+
completionTokens: finalCompletionTokens,
113+
totalTokens: totalTokens,
114+
copilotMultiplier,
115+
costResult,
116+
})
117+
118+
// Follow the exact same logic as ExecutionLogger.updateUserStats but with direct userId
119+
const costToStore = costResult.total // No additional multiplier needed since calculateCost already applied it
120+
121+
// Check if user stats record exists (same as ExecutionLogger)
122+
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
123+
124+
if (userStatsRecords.length === 0) {
125+
// Create new user stats record (same logic as ExecutionLogger)
126+
await db.insert(userStats).values({
127+
id: crypto.randomUUID(),
128+
userId: userId,
129+
totalManualExecutions: 0,
130+
totalApiCalls: 0,
131+
totalWebhookTriggers: 0,
132+
totalScheduledExecutions: 0,
133+
totalChatExecutions: 0,
134+
totalTokensUsed: totalTokens,
135+
totalCost: costToStore.toString(),
136+
currentPeriodCost: costToStore.toString(),
137+
lastActive: new Date(),
138+
})
139+
140+
logger.info(`[${requestId}] Created new user stats record`, {
141+
userId,
142+
totalCost: costToStore,
143+
totalTokens,
144+
})
145+
} else {
146+
// Update existing user stats record (same logic as ExecutionLogger)
147+
const updateFields = {
148+
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
149+
totalCost: sql`total_cost + ${costToStore}`,
150+
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
151+
totalApiCalls: sql`total_api_calls`,
152+
lastActive: new Date(),
153+
}
154+
155+
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
156+
157+
logger.info(`[${requestId}] Updated user stats record`, {
158+
userId,
159+
addedCost: costToStore,
160+
addedTokens: totalTokens,
161+
})
162+
}
163+
164+
const duration = Date.now() - startTime
165+
166+
logger.info(`[${requestId}] Cost update completed successfully`, {
167+
userId,
168+
duration,
169+
cost: costResult.total,
170+
totalTokens,
171+
})
172+
173+
return NextResponse.json({
174+
success: true,
175+
data: {
176+
userId,
177+
input,
178+
output,
179+
totalTokens,
180+
model,
181+
cost: {
182+
input: costResult.input,
183+
output: costResult.output,
184+
total: costResult.total,
185+
},
186+
tokenBreakdown: {
187+
prompt: finalPromptTokens,
188+
completion: finalCompletionTokens,
189+
total: totalTokens,
190+
},
191+
pricing: costResult.pricing,
192+
processedAt: new Date().toISOString(),
193+
requestId,
194+
},
195+
})
196+
} catch (error) {
197+
const duration = Date.now() - startTime
198+
199+
logger.error(`[${requestId}] Cost update failed`, {
200+
error: error instanceof Error ? error.message : String(error),
201+
stack: error instanceof Error ? error.stack : undefined,
202+
duration,
203+
})
204+
205+
return NextResponse.json(
206+
{
207+
success: false,
208+
error: 'Internal server error',
209+
requestId,
210+
},
211+
{ status: 500 }
212+
)
213+
}
214+
}

apps/sim/lib/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const env = createEnv({
6767
// Monitoring & Analytics
6868
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
6969
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
70+
COPILOT_COST_MULTIPLIER: z.number().optional(), // Multiplier for copilot cost calculations
7071
SENTRY_ORG: z.string().optional(), // Sentry organization for error tracking
7172
SENTRY_PROJECT: z.string().optional(), // Sentry project for error tracking
7273
SENTRY_AUTH_TOKEN: z.string().optional(), // Sentry authentication token

apps/sim/providers/utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -429,13 +429,15 @@ export async function transformBlockTool(
429429
* @param promptTokens Number of prompt tokens used
430430
* @param completionTokens Number of completion tokens used
431431
* @param useCachedInput Whether to use cached input pricing (default: false)
432+
* @param customMultiplier Optional custom multiplier to override the default cost multiplier
432433
* @returns Cost calculation results with input, output and total costs
433434
*/
434435
export function calculateCost(
435436
model: string,
436437
promptTokens = 0,
437438
completionTokens = 0,
438-
useCachedInput = false
439+
useCachedInput = false,
440+
customMultiplier?: number
439441
) {
440442
// First check if it's an embedding model
441443
let pricing = getEmbeddingModelPricing(model)
@@ -472,7 +474,7 @@ export function calculateCost(
472474
const outputCost = completionTokens * (pricing.output / 1_000_000)
473475
const totalCost = inputCost + outputCost
474476

475-
const costMultiplier = getCostMultiplier()
477+
const costMultiplier = customMultiplier ?? getCostMultiplier()
476478

477479
const finalInputCost = inputCost * costMultiplier
478480
const finalOutputCost = outputCost * costMultiplier

0 commit comments

Comments
 (0)