Skip to content

Commit 97b6bcc

Browse files
authored
v0.3.28: autolayout, export, copilot, kb ui improvements
2 parents a0cf003 + 42917ce commit 97b6bcc

File tree

49 files changed

+6958
-325
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+6958
-325
lines changed

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { eq, sql } from 'drizzle-orm'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { z } from 'zod'
55
import { checkInternalApiKey } from '@/lib/copilot/utils'
6-
import { env } from '@/lib/env'
7-
import { isBillingEnabled, isProd } from '@/lib/environment'
6+
import { isBillingEnabled } from '@/lib/environment'
87
import { createLogger } from '@/lib/logs/console/logger'
98
import { db } from '@/db'
109
import { userStats } from '@/db/schema'
@@ -17,6 +16,7 @@ const UpdateCostSchema = z.object({
1716
input: z.number().min(0, 'Input tokens must be a non-negative number'),
1817
output: z.number().min(0, 'Output tokens must be a non-negative number'),
1918
model: z.string().min(1, 'Model is required'),
19+
multiplier: z.number().min(0),
2020
})
2121

2222
/**
@@ -75,27 +75,27 @@ export async function POST(req: NextRequest) {
7575
)
7676
}
7777

78-
const { userId, input, output, model } = validation.data
78+
const { userId, input, output, model, multiplier } = validation.data
7979

8080
logger.info(`[${requestId}] Processing cost update`, {
8181
userId,
8282
input,
8383
output,
8484
model,
85+
multiplier,
8586
})
8687

8788
const finalPromptTokens = input
8889
const finalCompletionTokens = output
8990
const totalTokens = input + output
9091

91-
// Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions)
92-
const copilotMultiplier = isProd ? env.COPILOT_COST_MULTIPLIER || 1 : 1
92+
// Calculate cost using provided multiplier (required)
9393
const costResult = calculateCost(
9494
model,
9595
finalPromptTokens,
9696
finalCompletionTokens,
9797
false,
98-
copilotMultiplier
98+
multiplier
9999
)
100100

101101
logger.info(`[${requestId}] Cost calculation result`, {
@@ -104,7 +104,7 @@ export async function POST(req: NextRequest) {
104104
promptTokens: finalPromptTokens,
105105
completionTokens: finalCompletionTokens,
106106
totalTokens: totalTokens,
107-
copilotMultiplier,
107+
multiplier,
108108
costResult,
109109
})
110110

@@ -127,6 +127,10 @@ export async function POST(req: NextRequest) {
127127
totalTokensUsed: totalTokens,
128128
totalCost: costToStore.toString(),
129129
currentPeriodCost: costToStore.toString(),
130+
// Copilot usage tracking
131+
totalCopilotCost: costToStore.toString(),
132+
totalCopilotTokens: totalTokens,
133+
totalCopilotCalls: 1,
130134
lastActive: new Date(),
131135
})
132136

@@ -141,6 +145,10 @@ export async function POST(req: NextRequest) {
141145
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
142146
totalCost: sql`total_cost + ${costToStore}`,
143147
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
148+
// Copilot usage tracking increments
149+
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
150+
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
151+
totalCopilotCalls: sql`total_copilot_calls + 1`,
144152
totalApiCalls: sql`total_api_calls`,
145153
lastActive: new Date(),
146154
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { env } from '@/lib/env'
5+
import { createLogger } from '@/lib/logs/console/logger'
6+
import { generateApiKey } from '@/lib/utils'
7+
import { db } from '@/db'
8+
import { copilotApiKeys } from '@/db/schema'
9+
10+
const logger = createLogger('CopilotApiKeysGenerate')
11+
12+
function deriveKey(keyString: string): Buffer {
13+
return createHash('sha256').update(keyString, 'utf8').digest()
14+
}
15+
16+
function encryptRandomIv(plaintext: string, keyString: string): string {
17+
const key = deriveKey(keyString)
18+
const iv = randomBytes(16)
19+
const cipher = createCipheriv('aes-256-gcm', key, iv)
20+
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
21+
encrypted += cipher.final('hex')
22+
const authTag = cipher.getAuthTag().toString('hex')
23+
return `${iv.toString('hex')}:${encrypted}:${authTag}`
24+
}
25+
26+
function computeLookup(plaintext: string, keyString: string): string {
27+
// Deterministic, constant-time comparable MAC: HMAC-SHA256(DB_KEY, plaintext)
28+
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
29+
.update(plaintext, 'utf8')
30+
.digest('hex')
31+
}
32+
33+
export async function POST(req: NextRequest) {
34+
try {
35+
const session = await getSession()
36+
if (!session?.user?.id) {
37+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
38+
}
39+
40+
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
41+
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
42+
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
43+
}
44+
45+
const userId = session.user.id
46+
47+
// Generate and prefix the key (strip the generic sim_ prefix from the random part)
48+
const rawKey = generateApiKey().replace(/^sim_/, '')
49+
const plaintextKey = `sk-sim-copilot-${rawKey}`
50+
51+
// Encrypt with random IV for confidentiality
52+
const dbEncrypted = encryptRandomIv(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
53+
54+
// Compute deterministic lookup value for O(1) search
55+
const lookup = computeLookup(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
56+
57+
const [inserted] = await db
58+
.insert(copilotApiKeys)
59+
.values({ userId, apiKeyEncrypted: dbEncrypted, apiKeyLookup: lookup })
60+
.returning({ id: copilotApiKeys.id })
61+
62+
return NextResponse.json(
63+
{ success: true, key: { id: inserted.id, apiKey: plaintextKey } },
64+
{ status: 201 }
65+
)
66+
} catch (error) {
67+
logger.error('Failed to generate copilot API key', { error })
68+
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
69+
}
70+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createDecipheriv, createHash } from 'crypto'
2+
import { and, eq } from 'drizzle-orm'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { getSession } from '@/lib/auth'
5+
import { env } from '@/lib/env'
6+
import { createLogger } from '@/lib/logs/console/logger'
7+
import { db } from '@/db'
8+
import { copilotApiKeys } from '@/db/schema'
9+
10+
const logger = createLogger('CopilotApiKeys')
11+
12+
function deriveKey(keyString: string): Buffer {
13+
return createHash('sha256').update(keyString, 'utf8').digest()
14+
}
15+
16+
function decryptWithKey(encryptedValue: string, keyString: string): string {
17+
const parts = encryptedValue.split(':')
18+
if (parts.length !== 3) {
19+
throw new Error('Invalid encrypted value format')
20+
}
21+
const [ivHex, encryptedHex, authTagHex] = parts
22+
const key = deriveKey(keyString)
23+
const iv = Buffer.from(ivHex, 'hex')
24+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
25+
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
26+
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
27+
decrypted += decipher.final('utf8')
28+
return decrypted
29+
}
30+
31+
export async function GET(request: NextRequest) {
32+
try {
33+
const session = await getSession()
34+
if (!session?.user?.id) {
35+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
36+
}
37+
38+
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
39+
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
40+
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
41+
}
42+
43+
const userId = session.user.id
44+
45+
const rows = await db
46+
.select({ id: copilotApiKeys.id, apiKeyEncrypted: copilotApiKeys.apiKeyEncrypted })
47+
.from(copilotApiKeys)
48+
.where(eq(copilotApiKeys.userId, userId))
49+
50+
const keys = rows.map((row) => ({
51+
id: row.id,
52+
apiKey: decryptWithKey(row.apiKeyEncrypted, env.AGENT_API_DB_ENCRYPTION_KEY as string),
53+
}))
54+
55+
return NextResponse.json({ keys }, { status: 200 })
56+
} catch (error) {
57+
logger.error('Failed to get copilot API keys', { error })
58+
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
59+
}
60+
}
61+
62+
export async function DELETE(request: NextRequest) {
63+
try {
64+
const session = await getSession()
65+
if (!session?.user?.id) {
66+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
67+
}
68+
69+
const userId = session.user.id
70+
const url = new URL(request.url)
71+
const id = url.searchParams.get('id')
72+
if (!id) {
73+
return NextResponse.json({ error: 'id is required' }, { status: 400 })
74+
}
75+
76+
await db
77+
.delete(copilotApiKeys)
78+
.where(and(eq(copilotApiKeys.userId, userId), eq(copilotApiKeys.id, id)))
79+
80+
return NextResponse.json({ success: true }, { status: 200 })
81+
} catch (error) {
82+
logger.error('Failed to delete copilot API key', { error })
83+
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
84+
}
85+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { createHmac } from 'crypto'
2+
import { eq } from 'drizzle-orm'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { env } from '@/lib/env'
5+
import { createLogger } from '@/lib/logs/console/logger'
6+
import { db } from '@/db'
7+
import { copilotApiKeys, userStats } from '@/db/schema'
8+
9+
const logger = createLogger('CopilotApiKeysValidate')
10+
11+
function computeLookup(plaintext: string, keyString: string): string {
12+
// Deterministic MAC: HMAC-SHA256(DB_KEY, plaintext)
13+
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
14+
.update(plaintext, 'utf8')
15+
.digest('hex')
16+
}
17+
18+
export async function POST(req: NextRequest) {
19+
try {
20+
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
21+
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
22+
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
23+
}
24+
25+
const body = await req.json().catch(() => null)
26+
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : undefined
27+
28+
if (!apiKey) {
29+
return new NextResponse(null, { status: 401 })
30+
}
31+
32+
const lookup = computeLookup(apiKey, env.AGENT_API_DB_ENCRYPTION_KEY)
33+
34+
// Find matching API key and its user
35+
const rows = await db
36+
.select({ id: copilotApiKeys.id, userId: copilotApiKeys.userId })
37+
.from(copilotApiKeys)
38+
.where(eq(copilotApiKeys.apiKeyLookup, lookup))
39+
.limit(1)
40+
41+
if (rows.length === 0) {
42+
return new NextResponse(null, { status: 401 })
43+
}
44+
45+
const { userId } = rows[0]
46+
47+
// Check usage for the associated user
48+
const usage = await db
49+
.select({
50+
currentPeriodCost: userStats.currentPeriodCost,
51+
totalCost: userStats.totalCost,
52+
currentUsageLimit: userStats.currentUsageLimit,
53+
})
54+
.from(userStats)
55+
.where(eq(userStats.userId, userId))
56+
.limit(1)
57+
58+
if (usage.length > 0) {
59+
const currentUsage = Number.parseFloat(
60+
(usage[0].currentPeriodCost?.toString() as string) ||
61+
(usage[0].totalCost as unknown as string) ||
62+
'0'
63+
)
64+
const limit = Number.parseFloat((usage[0].currentUsageLimit as unknown as string) || '0')
65+
66+
if (!Number.isNaN(limit) && limit > 0 && currentUsage >= limit) {
67+
// Usage exceeded
68+
return new NextResponse(null, { status: 402 })
69+
}
70+
}
71+
72+
// Valid and within usage limits
73+
return new NextResponse(null, { status: 200 })
74+
} catch (error) {
75+
logger.error('Error validating copilot API key', { error })
76+
return NextResponse.json({ error: 'Failed to validate key' }, { status: 500 })
77+
}
78+
}

apps/sim/app/api/copilot/chat/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe('Copilot Chat API Route', () => {
104104
vi.doMock('@/lib/env', () => ({
105105
env: {
106106
SIM_AGENT_API_URL: 'http://localhost:8000',
107-
SIM_AGENT_API_KEY: 'test-sim-agent-key',
107+
COPILOT_API_KEY: 'test-sim-agent-key',
108108
},
109109
}))
110110

0 commit comments

Comments
 (0)