feat(copilot): generate agent api key (#989)

* Add skeleton copilot to settings modal and add migration for copilot api keys

* Add hash index on encrypted key

* Security 1

* Remove sim agent api key

* Fix api key stuff

* Auth

* Status code handling

* Update env key

* Copilot api key ui

* Update copilot costs

* Add copilot stats

* Lint

* Remove logs

* Remove migrations

* Remove another migration

* Updates

* Hide if hosted

* Fix test

* Lint

* Lint

* Fixes

* Lint

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
This commit is contained in:
Siddharth Ganesan
2025-08-15 18:05:54 -07:00
committed by GitHub
parent 0258a1b4ce
commit c6166a9483
35 changed files with 6798 additions and 183 deletions

View File

@@ -3,8 +3,7 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { env } from '@/lib/env'
import { isBillingEnabled, isProd } from '@/lib/environment'
import { isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { userStats } from '@/db/schema'
@@ -17,6 +16,7 @@ const UpdateCostSchema = z.object({
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'),
multiplier: z.number().min(0),
})
/**
@@ -75,27 +75,27 @@ export async function POST(req: NextRequest) {
)
}
const { userId, input, output, model } = validation.data
const { userId, input, output, model, multiplier } = validation.data
logger.info(`[${requestId}] Processing cost update`, {
userId,
input,
output,
model,
multiplier,
})
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
// Calculate cost using provided multiplier (required)
const costResult = calculateCost(
model,
finalPromptTokens,
finalCompletionTokens,
false,
copilotMultiplier
multiplier
)
logger.info(`[${requestId}] Cost calculation result`, {
@@ -104,7 +104,7 @@ export async function POST(req: NextRequest) {
promptTokens: finalPromptTokens,
completionTokens: finalCompletionTokens,
totalTokens: totalTokens,
copilotMultiplier,
multiplier,
costResult,
})
@@ -127,6 +127,10 @@ export async function POST(req: NextRequest) {
totalTokensUsed: totalTokens,
totalCost: costToStore.toString(),
currentPeriodCost: costToStore.toString(),
// Copilot usage tracking
totalCopilotCost: costToStore.toString(),
totalCopilotTokens: totalTokens,
totalCopilotCalls: 1,
lastActive: new Date(),
})
@@ -141,6 +145,10 @@ export async function POST(req: NextRequest) {
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
totalCost: sql`total_cost + ${costToStore}`,
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
// Copilot usage tracking increments
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
totalCopilotCalls: sql`total_copilot_calls + 1`,
totalApiCalls: sql`total_api_calls`,
lastActive: new Date(),
}

View File

@@ -0,0 +1,70 @@
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { generateApiKey } from '@/lib/utils'
import { db } from '@/db'
import { copilotApiKeys } from '@/db/schema'
const logger = createLogger('CopilotApiKeysGenerate')
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function encryptRandomIv(plaintext: string, keyString: string): string {
const key = deriveKey(keyString)
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return `${iv.toString('hex')}:${encrypted}:${authTag}`
}
function computeLookup(plaintext: string, keyString: string): string {
// Deterministic, constant-time comparable MAC: HMAC-SHA256(DB_KEY, plaintext)
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
.update(plaintext, 'utf8')
.digest('hex')
}
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const userId = session.user.id
// Generate and prefix the key (strip the generic sim_ prefix from the random part)
const rawKey = generateApiKey().replace(/^sim_/, '')
const plaintextKey = `sk-sim-copilot-${rawKey}`
// Encrypt with random IV for confidentiality
const dbEncrypted = encryptRandomIv(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
// Compute deterministic lookup value for O(1) search
const lookup = computeLookup(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
const [inserted] = await db
.insert(copilotApiKeys)
.values({ userId, apiKeyEncrypted: dbEncrypted, apiKeyLookup: lookup })
.returning({ id: copilotApiKeys.id })
return NextResponse.json(
{ success: true, key: { id: inserted.id, apiKey: plaintextKey } },
{ status: 201 }
)
} catch (error) {
logger.error('Failed to generate copilot API key', { error })
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { createDecipheriv, createHash } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { copilotApiKeys } from '@/db/schema'
const logger = createLogger('CopilotApiKeys')
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function decryptWithKey(encryptedValue: string, keyString: string): string {
const parts = encryptedValue.split(':')
if (parts.length !== 3) {
throw new Error('Invalid encrypted value format')
}
const [ivHex, encryptedHex, authTagHex] = parts
const key = deriveKey(keyString)
const iv = Buffer.from(ivHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const userId = session.user.id
const rows = await db
.select({ id: copilotApiKeys.id, apiKeyEncrypted: copilotApiKeys.apiKeyEncrypted })
.from(copilotApiKeys)
.where(eq(copilotApiKeys.userId, userId))
const keys = rows.map((row) => ({
id: row.id,
apiKey: decryptWithKey(row.apiKeyEncrypted, env.AGENT_API_DB_ENCRYPTION_KEY as string),
}))
return NextResponse.json({ keys }, { status: 200 })
} catch (error) {
logger.error('Failed to get copilot API keys', { error })
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const url = new URL(request.url)
const id = url.searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 })
}
await db
.delete(copilotApiKeys)
.where(and(eq(copilotApiKeys.userId, userId), eq(copilotApiKeys.id, id)))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete copilot API key', { error })
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
}
}

View File

@@ -0,0 +1,78 @@
import { createHmac } from 'crypto'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { copilotApiKeys, userStats } from '@/db/schema'
const logger = createLogger('CopilotApiKeysValidate')
function computeLookup(plaintext: string, keyString: string): string {
// Deterministic MAC: HMAC-SHA256(DB_KEY, plaintext)
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
.update(plaintext, 'utf8')
.digest('hex')
}
export async function POST(req: NextRequest) {
try {
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const body = await req.json().catch(() => null)
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : undefined
if (!apiKey) {
return new NextResponse(null, { status: 401 })
}
const lookup = computeLookup(apiKey, env.AGENT_API_DB_ENCRYPTION_KEY)
// Find matching API key and its user
const rows = await db
.select({ id: copilotApiKeys.id, userId: copilotApiKeys.userId })
.from(copilotApiKeys)
.where(eq(copilotApiKeys.apiKeyLookup, lookup))
.limit(1)
if (rows.length === 0) {
return new NextResponse(null, { status: 401 })
}
const { userId } = rows[0]
// Check usage for the associated user
const usage = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
totalCost: userStats.totalCost,
currentUsageLimit: userStats.currentUsageLimit,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (usage.length > 0) {
const currentUsage = Number.parseFloat(
(usage[0].currentPeriodCost?.toString() as string) ||
(usage[0].totalCost as unknown as string) ||
'0'
)
const limit = Number.parseFloat((usage[0].currentUsageLimit as unknown as string) || '0')
if (!Number.isNaN(limit) && limit > 0 && currentUsage >= limit) {
// Usage exceeded
return new NextResponse(null, { status: 402 })
}
}
// Valid and within usage limits
return new NextResponse(null, { status: 200 })
} catch (error) {
logger.error('Error validating copilot API key', { error })
return NextResponse.json({ error: 'Failed to validate key' }, { status: 500 })
}
}

View File

@@ -104,7 +104,7 @@ describe('Copilot Chat API Route', () => {
vi.doMock('@/lib/env', () => ({
env: {
SIM_AGENT_API_URL: 'http://localhost:8000',
SIM_AGENT_API_KEY: 'test-sim-agent-key',
COPILOT_API_KEY: 'test-sim-agent-key',
},
}))

View File

@@ -1,3 +1,4 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -13,6 +14,7 @@ import { getCopilotModel } from '@/lib/copilot/config'
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { downloadFile } from '@/lib/uploads'
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
@@ -23,6 +25,37 @@ import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
const logger = createLogger('CopilotChatAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function decryptWithKey(encryptedValue: string, keyString: string): string {
const [ivHex, encryptedHex, authTagHex] = encryptedValue.split(':')
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error('Invalid encrypted format')
}
const key = deriveKey(keyString)
const iv = Buffer.from(ivHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
function encryptWithKey(plaintext: string, keyString: string): string {
const key = deriveKey(keyString)
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return `${iv.toString('hex')}:${encrypted}:${authTag}`
}
// Schema for file attachments
const FileAttachmentSchema = z.object({
id: z.string(),
@@ -48,10 +81,6 @@ const ChatMessageSchema = z.object({
conversationId: z.string().optional(),
})
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
/**
* Generate a chat title using LLM
*/
@@ -179,6 +208,7 @@ export async function POST(req: NextRequest) {
hasImplicitFeedback: !!implicitFeedback,
provider: provider || 'openai',
hasConversationId: !!conversationId,
depth,
})
// Handle chat context
@@ -347,7 +377,7 @@ export async function POST(req: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({
messages: messagesForAgent,
@@ -364,11 +394,17 @@ export async function POST(req: NextRequest) {
})
if (!simAgentResponse.ok) {
const errorText = await simAgentResponse.text()
if (simAgentResponse.status === 401 || simAgentResponse.status === 402) {
// Rethrow status only; client will render appropriate assistant message
return new NextResponse(null, { status: simAgentResponse.status })
}
const errorText = await simAgentResponse.text().catch(() => '')
logger.error(`[${tracker.requestId}] Sim agent API error:`, {
status: simAgentResponse.status,
error: errorText,
})
return NextResponse.json(
{ error: `Sim agent API error: ${simAgentResponse.statusText}` },
{ status: simAgentResponse.status }

View File

@@ -48,11 +48,6 @@ async function updateToolCallStatus(
while (Date.now() - startTime < timeout) {
const exists = await redis.exists(key)
if (exists) {
logger.info('Tool call found in Redis, updating status', {
toolCallId,
key,
pollDuration: Date.now() - startTime,
})
break
}
@@ -79,27 +74,8 @@ async function updateToolCallStatus(
timestamp: new Date().toISOString(),
}
// Log what we're about to update in Redis
logger.info('About to update Redis with tool call data', {
toolCallId,
key,
toolCallData,
serializedData: JSON.stringify(toolCallData),
providedStatus: status,
providedMessage: message,
messageIsUndefined: message === undefined,
messageIsNull: message === null,
})
await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400) // Keep 24 hour expiry
logger.info('Tool call status updated in Redis', {
toolCallId,
key,
status,
message,
pollDuration: Date.now() - startTime,
})
return true
} catch (error) {
logger.error('Failed to update tool call status in Redis', {
@@ -131,13 +107,6 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
logger.info(`[${tracker.requestId}] Tool call confirmation request`, {
userId: authenticatedUserId,
toolCallId,
status,
message,
})
// Update the tool call status in Redis
const updated = await updateToolCallStatus(toolCallId, status, message)
@@ -153,13 +122,6 @@ export async function POST(req: NextRequest) {
}
const duration = tracker.getDuration()
logger.info(`[${tracker.requestId}] Tool call confirmation completed`, {
userId: authenticatedUserId,
toolCallId,
status,
internalStatus: status,
duration,
})
return NextResponse.json({
success: true,

View File

@@ -69,12 +69,6 @@ async function pollRedisForTool(
const pollInterval = 1000 // 1 second
const startTime = Date.now()
logger.info('Starting to poll Redis for tool call status', {
toolCallId,
timeout,
pollInterval,
})
while (Date.now() - startTime < timeout) {
try {
const redisValue = await redis.get(key)
@@ -112,23 +106,6 @@ async function pollRedisForTool(
rawRedisValue: redisValue,
})
logger.info('Tool call status resolved', {
toolCallId,
status,
message,
duration: Date.now() - startTime,
rawRedisValue: redisValue,
parsedAsJSON: redisValue
? (() => {
try {
return JSON.parse(redisValue)
} catch {
return 'failed-to-parse'
}
})()
: null,
})
// Special logging for set environment variables tool when Redis status is found
if (toolCallId && (status === 'accepted' || status === 'rejected')) {
logger.info('SET_ENV_VARS: Redis polling found status update', {

View File

@@ -17,12 +17,6 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('AutoLayoutAPI')
// Check API key configuration at module level
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
if (!SIM_AGENT_API_KEY) {
logger.warn('SIM_AGENT_API_KEY not configured - autolayout requests will fail')
}
const AutoLayoutRequestSchema = z.object({
strategy: z
.enum(['smart', 'hierarchical', 'layered', 'force-directed'])
@@ -125,15 +119,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
}
// Apply autolayout
logger.info(
`[${requestId}] Applying autolayout to ${Object.keys(currentWorkflowData.blocks).length} blocks`,
{
hasApiKey: !!SIM_AGENT_API_KEY,
simAgentUrl: process.env.SIM_AGENT_API_URL || 'http://localhost:8000',
}
)
// Create workflow state for autolayout
const workflowState = {
blocks: currentWorkflowData.blocks,
@@ -184,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
resolveOutputType: resolveOutputType.toString(),
},
},
apiKey: SIM_AGENT_API_KEY,
})
// Log the full response for debugging

View File

@@ -1,9 +1,10 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { simAgentClient } from '@/lib/sim-agent'
import { SIM_AGENT_API_URL_DEFAULT, simAgentClient } from '@/lib/sim-agent'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
@@ -18,8 +19,7 @@ import { workflowCheckpoints, workflow as workflowTable } from '@/db/schema'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
export const dynamic = 'force-dynamic'
@@ -74,7 +74,6 @@ async function createWorkflowCheckpoint(
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
workflowState: currentWorkflowData,
@@ -288,7 +287,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,

View File

@@ -8,9 +8,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('WorkflowYamlAPI')
// Get API key at module level like working routes
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
@@ -55,7 +52,6 @@ export async function POST(request: NextRequest) {
resolveOutputType: resolveOutputType.toString(),
},
},
apiKey: SIM_AGENT_API_KEY,
})
if (!result.success || !result.data?.yaml) {

View File

@@ -14,9 +14,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('WorkflowYamlExportAPI')
// Get API key at module level like working routes
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
const url = new URL(request.url)
@@ -176,7 +173,6 @@ export async function GET(request: NextRequest) {
resolveOutputType: resolveOutputType.toString(),
},
},
apiKey: SIM_AGENT_API_KEY,
})
if (!result.success || !result.data?.yaml) {

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -16,8 +18,7 @@ import {
const logger = createLogger('YamlAutoLayoutAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const AutoLayoutRequestSchema = z.object({
workflowState: z.object({
@@ -58,7 +59,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Applying auto layout`, {
blockCount: Object.keys(workflowState.blocks).length,
edgeCount: workflowState.edges.length,
hasApiKey: !!SIM_AGENT_API_KEY,
strategy: options?.strategy || 'smart',
simAgentUrl: SIM_AGENT_API_URL,
})
@@ -102,7 +102,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
workflowState: {

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -16,8 +18,7 @@ import {
const logger = createLogger('YamlDiffCreateAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const CreateDiffRequestSchema = z.object({
yamlContent: z.string().min(1),
@@ -89,7 +90,6 @@ export async function POST(request: NextRequest) {
hasDiffAnalysis: !!diffAnalysis,
hasOptions: !!options,
options: options,
hasApiKey: !!SIM_AGENT_API_KEY,
hasCurrentWorkflowState: !!currentWorkflowState,
currentBlockCount: currentWorkflowState
? Object.keys(currentWorkflowState.blocks || {}).length
@@ -117,7 +117,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -16,8 +18,7 @@ import {
const logger = createLogger('YamlDiffMergeAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const MergeDiffRequestSchema = z.object({
existingDiff: z.object({
@@ -64,7 +65,6 @@ export async function POST(request: NextRequest) {
hasDiffAnalysis: !!diffAnalysis,
hasOptions: !!options,
options: options,
hasApiKey: !!SIM_AGENT_API_KEY,
})
// Gather block registry
@@ -88,7 +88,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
existingDiff,

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('YamlGenerateAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const GenerateRequestSchema = z.object({
workflowState: z.any(), // Let the yaml service handle validation
@@ -27,7 +28,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Generating YAML from workflow`, {
blocksCount: workflowState.blocks ? Object.keys(workflowState.blocks).length : 0,
edgesCount: workflowState.edges ? workflowState.edges.length : 0,
hasApiKey: !!SIM_AGENT_API_KEY,
})
// Gather block registry and utilities
@@ -51,7 +51,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
workflowState,

View File

@@ -1,26 +1,24 @@
import { NextResponse } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
const logger = createLogger('YamlHealthAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
export async function GET() {
const requestId = crypto.randomUUID().slice(0, 8)
try {
logger.info(`[${requestId}] Checking YAML service health`, {
hasApiKey: !!SIM_AGENT_API_KEY,
})
logger.info(`[${requestId}] Checking YAML service health`)
// Check sim-agent health
const response = await fetch(`${SIM_AGENT_API_URL}/health`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
})

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -9,11 +11,10 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('YamlParseAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const ParseRequestSchema = z.object({
yamlContent: z.string().min(1),
yamlContent: z.string(),
})
export async function POST(request: NextRequest) {
@@ -25,7 +26,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Parsing YAML`, {
contentLength: yamlContent.length,
hasApiKey: !!SIM_AGENT_API_KEY,
})
// Gather block registry and utilities
@@ -49,7 +49,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('YamlToWorkflowAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const ConvertRequestSchema = z.object({
yamlContent: z.string().min(1),
@@ -33,7 +34,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Converting YAML to workflow`, {
contentLength: yamlContent.length,
hasOptions: !!options,
hasApiKey: !!SIM_AGENT_API_KEY,
})
// Gather block registry and utilities
@@ -57,7 +57,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,

View File

@@ -0,0 +1,367 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, Copy, Eye, EyeOff, KeySquare, Plus, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Card,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Skeleton,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotSettings')
interface CopilotKey {
id: string
apiKey: string
}
export function Copilot() {
const [keys, setKeys] = useState<CopilotKey[]>([])
const [isLoading, setIsLoading] = useState(false)
const [visible, setVisible] = useState<Record<string, boolean>>({})
// Create flow state
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [newKey, setNewKey] = useState<CopilotKey | null>(null)
const [copiedKeyIds, setCopiedKeyIds] = useState<Record<string, boolean>>({})
const [newKeyCopySuccess, setNewKeyCopySuccess] = useState(false)
// Delete flow state
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const hasKeys = keys.length > 0
const maskedValue = useCallback((value: string, show: boolean) => {
if (show) return value
if (!value) return ''
const last6 = value.slice(-6)
return `••••••••••${last6}`
}, [])
const fetchKeys = useCallback(async () => {
try {
setIsLoading(true)
const res = await fetch('/api/copilot/api-keys')
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
const data = await res.json()
setKeys(Array.isArray(data.keys) ? data.keys : [])
} catch (error) {
logger.error('Failed to fetch copilot keys', { error })
setKeys([])
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchKeys()
}, [fetchKeys])
const onGenerate = async () => {
try {
setIsLoading(true)
const res = await fetch('/api/copilot/api-keys/generate', { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Failed to generate API key')
}
const data = await res.json()
// Show the new key dialog with the API key (only shown once)
if (data?.key) {
setNewKey(data.key)
setShowNewKeyDialog(true)
}
await fetchKeys()
} catch (error) {
logger.error('Failed to generate copilot API key', { error })
} finally {
setIsLoading(false)
}
}
const onDelete = async (id: string) => {
try {
setIsLoading(true)
const res = await fetch(`/api/copilot/api-keys?id=${encodeURIComponent(id)}`, {
method: 'DELETE',
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Failed to delete API key')
}
await fetchKeys()
} catch (error) {
logger.error('Failed to delete copilot API key', { error })
} finally {
setIsLoading(false)
}
}
const onCopy = async (value: string, keyId?: string) => {
try {
await navigator.clipboard.writeText(value)
if (keyId) {
setCopiedKeyIds((prev) => ({ ...prev, [keyId]: true }))
setTimeout(() => {
setCopiedKeyIds((prev) => ({ ...prev, [keyId]: false }))
}, 1500)
} else {
setNewKeyCopySuccess(true)
setTimeout(() => setNewKeyCopySuccess(false), 1500)
}
} catch (error) {
logger.error('Copy failed', { error })
}
}
// UI helpers
const isFetching = isLoading && keys.length === 0
return (
<div className='space-y-6 p-6'>
<h2 className='font-semibold text-xl'>Copilot API Keys</h2>
<p className='text-muted-foreground text-sm leading-relaxed'>
Copilot API keys let you authenticate requests to the Copilot endpoints. Keep keys secret
and rotate them regularly.
</p>
<p className='text-muted-foreground text-xs italic'>
For external deployments, set the <span className='font-mono'>COPILOT_API_KEY</span>{' '}
environment variable on that instance to one of the keys generated here.
</p>
{isFetching ? (
<div className='mt-6 space-y-3'>
<Card className='p-4'>
<div className='flex items-center justify-between'>
<div>
<Skeleton className='mb-2 h-5 w-32' />
<Skeleton className='h-4 w-48' />
</div>
<Skeleton className='h-8 w-8 rounded-md' />
</div>
</Card>
<Card className='p-4'>
<div className='flex items-center justify-between'>
<div>
<Skeleton className='mb-2 h-5 w-28' />
<Skeleton className='h-4 w-40' />
</div>
<Skeleton className='h-8 w-8 rounded-md' />
</div>
</Card>
</div>
) : !hasKeys ? (
<div className='mt-6 rounded-md border border-dashed p-8'>
<div className='flex flex-col items-center justify-center text-center'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-muted'>
<KeySquare className='h-6 w-6 text-primary' />
</div>
<h3 className='mt-4 font-medium text-lg'>No Copilot keys yet</h3>
<p className='mt-2 max-w-sm text-muted-foreground text-sm'>
Generate a Copilot API key to authenticate requests to the Copilot SDK and methods.
</p>
<Button
variant='default'
className='mt-4'
onClick={onGenerate}
size='sm'
disabled={isLoading}
>
<Plus className='mr-1.5 h-4 w-4' /> Generate Key
</Button>
</div>
</div>
) : (
<div className='mt-6 space-y-4'>
{keys.map((k) => {
const isVisible = !!visible[k.id]
const value = maskedValue(k.apiKey, isVisible)
return (
<Card key={k.id} className='p-4 transition-shadow hover:shadow-sm'>
<div className='flex items-center justify-between gap-4'>
<div className='min-w-0 flex-1'>
<div className='rounded bg-muted/50 px-2 py-1 font-mono text-sm'>{value}</div>
<p className='mt-1 text-muted-foreground text-xs'>
Key ID: <span className='font-mono'>{k.id}</span>
</p>
</div>
<div className='flex items-center gap-2'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='secondary'
size='icon'
onClick={() => setVisible((v) => ({ ...v, [k.id]: !isVisible }))}
className='h-8 w-8'
>
{isVisible ? (
<EyeOff className='h-4 w-4' />
) : (
<Eye className='h-4 w-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isVisible ? 'Hide' : 'Reveal'}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='secondary'
size='icon'
onClick={() => onCopy(k.apiKey, k.id)}
className='h-8 w-8'
>
{copiedKeyIds[k.id] ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setDeleteKey(k)
setShowDeleteDialog(true)
}}
className='h-8 w-8 text-destructive hover:bg-destructive/10'
>
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
)
})}
</div>
)}
{/* New Key Dialog */}
<Dialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) setNewKey(null)
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Your Copilot API key has been created</DialogTitle>
<DialogDescription>
This is the only time you will see the full key. Copy it now and store it securely.
</DialogDescription>
</DialogHeader>
{newKey && (
<div className='space-y-4 py-3'>
<div className='space-y-2'>
<Label>API Key</Label>
<div className='relative'>
<Input
readOnly
value={newKey.apiKey}
className='border-slate-300 bg-muted/50 pr-10 font-mono text-sm'
/>
<Button
variant='ghost'
size='sm'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7'
onClick={() => onCopy(newKey.apiKey)}
>
{newKeyCopySuccess ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
<p className='mt-1 text-muted-foreground text-xs'>
For security, we don't store the complete key. You won't be able to view it again.
</p>
</div>
</div>
)}
<DialogFooter className='sm:justify-end'>
<Button
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className='sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Delete Copilot API Key</AlertDialogTitle>
<AlertDialogDescription>
{deleteKey && (
<>
Are you sure you want to delete this Copilot API key? This action cannot be
undone.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='gap-2 sm:justify-end'>
<AlertDialogCancel onClick={() => setDeleteKey(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteKey) {
onDelete(deleteKey.id)
}
setShowDeleteDialog(false)
setDeleteKey(null)
}}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,5 +1,6 @@
export { Account } from './account/account'
export { ApiKeys } from './api-keys/api-keys'
export { Copilot } from './copilot/copilot'
export { Credentials } from './credentials/credentials'
export { EnvironmentVariables } from './environment/environment'
export { General } from './general/general'

View File

@@ -1,4 +1,5 @@
import {
Bot,
CreditCard,
KeyRound,
KeySquare,
@@ -9,6 +10,7 @@ import {
Users,
} from 'lucide-react'
import { getEnv, isTruthy } from '@/lib/env'
import { isHosted } from '@/lib/environment'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -26,6 +28,7 @@ interface SettingsNavigationProps {
| 'subscription'
| 'team'
| 'privacy'
| 'copilot'
) => void
hasOrganization: boolean
}
@@ -39,6 +42,7 @@ type NavigationItem = {
| 'apikeys'
| 'subscription'
| 'team'
| 'copilot'
| 'privacy'
label: string
icon: React.ComponentType<{ className?: string }>
@@ -72,6 +76,11 @@ const allNavigationItems: NavigationItem[] = [
label: 'API Keys',
icon: KeySquare,
},
{
id: 'copilot',
label: 'Copilot',
icon: Bot,
},
{
id: 'privacy',
label: 'Privacy',
@@ -101,6 +110,9 @@ export function SettingsNavigation({
const subscription = getSubscriptionStatus()
const navigationItems = allNavigationItems.filter((item) => {
if (item.id === 'copilot' && !isHosted) {
return false
}
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
return false
}

View File

@@ -4,11 +4,13 @@ import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/env'
import { isHosted } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import {
Account,
ApiKeys,
Copilot,
Credentials,
EnvironmentVariables,
General,
@@ -38,6 +40,7 @@ type SettingsSection =
| 'subscription'
| 'team'
| 'privacy'
| 'copilot'
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
@@ -148,6 +151,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<TeamManagement />
</div>
)}
{isHosted && (
<div className={cn('h-full', activeSection === 'copilot' ? 'block' : 'hidden')}>
<Copilot />
</div>
)}
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
<Privacy />
</div>

View File

@@ -0,0 +1,13 @@
CREATE TABLE "copilot_api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"api_key_encrypted" text NOT NULL,
"api_key_lookup" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_stats" ADD COLUMN "total_copilot_cost" numeric DEFAULT '0' NOT NULL;--> statement-breakpoint
ALTER TABLE "user_stats" ADD COLUMN "total_copilot_tokens" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "user_stats" ADD COLUMN "total_copilot_calls" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "copilot_api_keys" ADD CONSTRAINT "copilot_api_keys_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "copilot_api_keys_api_key_encrypted_hash_idx" ON "copilot_api_keys" USING hash ("api_key_encrypted");--> statement-breakpoint
CREATE INDEX "copilot_api_keys_lookup_hash_idx" ON "copilot_api_keys" USING hash ("api_key_lookup");

File diff suppressed because it is too large Load Diff

View File

@@ -512,6 +512,13 @@
"when": 1755286337930,
"tag": "0073_hot_champions",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1755304368539,
"tag": "0074_abnormal_dreadnoughts",
"breakpoints": true
}
]
}

View File

@@ -465,6 +465,10 @@ export const userStats = pgTable('user_stats', {
billingPeriodStart: timestamp('billing_period_start').defaultNow(), // When current billing period started
billingPeriodEnd: timestamp('billing_period_end'), // When current billing period ends
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
// Copilot usage tracking
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0),
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
lastActive: timestamp('last_active').notNull().defaultNow(),
})
@@ -1178,3 +1182,25 @@ export const copilotFeedback = pgTable(
createdAtIdx: index('copilot_feedback_created_at_idx').on(table.createdAt),
})
)
export const copilotApiKeys = pgTable(
'copilot_api_keys',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
apiKeyEncrypted: text('api_key_encrypted').notNull(),
apiKeyLookup: text('api_key_lookup').notNull(),
},
(table) => ({
apiKeyEncryptedHashIdx: index('copilot_api_keys_api_key_encrypted_hash_idx').using(
'hash',
table.apiKeyEncrypted
),
apiKeyLookupHashIdx: index('copilot_api_keys_lookup_hash_idx').using(
'hash',
table.apiKeyLookup
),
})
)

View File

@@ -71,6 +71,7 @@ export interface SendMessageRequest {
export interface ApiResponse {
success: boolean
error?: string
status?: number
}
/**
@@ -86,7 +87,7 @@ export interface StreamingResponse extends ApiResponse {
async function handleApiError(response: Response, defaultMessage: string): Promise<string> {
try {
const data = await response.json()
return data.error || defaultMessage
return (data && (data.error || data.message)) || defaultMessage
} catch {
return `${defaultMessage} (${response.status})`
}
@@ -111,11 +112,19 @@ export async function sendStreamingMessage(
if (!response.ok) {
const errorMessage = await handleApiError(response, 'Failed to send streaming message')
throw new Error(errorMessage)
return {
success: false,
error: errorMessage,
status: response.status,
}
}
if (!response.body) {
throw new Error('No response body received')
return {
success: false,
error: 'No response body received',
status: 500,
}
}
return {

View File

@@ -1,5 +1,6 @@
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -7,8 +8,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
import { BaseCopilotTool } from '../base'
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
interface BuildWorkflowParams {
yamlContent: string
@@ -71,7 +71,6 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise<BuildWorkflow
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,

View File

@@ -1,5 +1,6 @@
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -8,8 +9,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('EditWorkflowAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
// Types for operations
interface EditWorkflowOperation {
@@ -46,7 +46,6 @@ async function applyOperationsToYaml(
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent: currentYaml,
@@ -481,7 +480,6 @@ async function editWorkflow(params: EditWorkflowParams): Promise<EditWorkflowRes
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
workflowState,

View File

@@ -24,8 +24,10 @@ export const env = createEnv({
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
SIM_AGENT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
COPILOT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API
AGENT_API_DB_ENCRYPTION_KEY: z.string().min(32).optional(), // Key for encrypting sensitive data for sim agent
AGENT_API_NETWORK_ENCRYPTION_KEY: z.string().min(32).optional(), // Key for encrypting sensitive data for sim agent
// Database & Storage
POSTGRES_URL: z.string().url().optional(), // Alternative PostgreSQL connection string
@@ -69,7 +71,6 @@ 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

View File

@@ -1,12 +1,11 @@
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
const logger = createLogger('SimAgentClient')
// Base URL for the sim-agent service
const SIM_AGENT_BASE_URL =
process.env.NODE_ENV === 'development'
? 'http://localhost:8000'
: process.env.NEXT_PUBLIC_SIM_AGENT_URL || 'https://sim-agent.vercel.app'
const SIM_AGENT_BASE_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
export interface SimAgentRequest {
workflowId: string
@@ -28,31 +27,6 @@ class SimAgentClient {
this.baseUrl = SIM_AGENT_BASE_URL
}
/**
* Get the API key lazily to ensure environment variables are loaded
*/
private getApiKey(): string {
// Only try server-side env var (never expose to client)
let apiKey = process.env.SIM_AGENT_API_KEY || ''
// If not found, try importing env library as fallback
if (!apiKey) {
try {
const { env } = require('@/lib/env')
apiKey = env.SIM_AGENT_API_KEY || ''
} catch (e) {
// env library not available or failed to load
}
}
if (!apiKey && typeof window === 'undefined') {
// Only warn on server-side where API key should be available
logger.warn('SIM_AGENT_API_KEY not configured')
}
return apiKey
}
/**
* Make a request to the sim-agent service
*/
@@ -66,23 +40,20 @@ class SimAgentClient {
} = {}
): Promise<SimAgentResponse<T>> {
const requestId = crypto.randomUUID().slice(0, 8)
const { method = 'POST', body, headers = {}, apiKey: providedApiKey } = options
const { method = 'POST', body, headers = {} } = options
try {
const url = `${this.baseUrl}${endpoint}`
// Use provided API key or try to get it from environment
const apiKey = providedApiKey || this.getApiKey()
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...(apiKey && { 'x-api-key': apiKey }),
...headers,
}
logger.info(`[${requestId}] Making request to sim-agent`, {
url,
method,
hasApiKey: !!apiKey,
hasBody: !!body,
})
@@ -157,7 +128,6 @@ class SimAgentClient {
getConfig() {
return {
baseUrl: this.baseUrl,
hasApiKey: !!this.getApiKey(),
environment: process.env.NODE_ENV,
}
}

View File

@@ -0,0 +1 @@
export const SIM_AGENT_API_URL_DEFAULT = 'https://agent.sim.ai'

View File

@@ -2,6 +2,7 @@
export type { SimAgentRequest, SimAgentResponse } from './client'
export { SimAgentClient, simAgentClient } from './client'
export { SIM_AGENT_API_URL_DEFAULT } from './constants'
// Import for default export
import { simAgentClient } from './client'

View File

@@ -238,6 +238,13 @@ function createErrorMessage(messageId: string, content: string): CopilotMessage
role: 'assistant',
content,
timestamp: new Date().toISOString(),
contentBlocks: [
{
type: 'text',
content,
timestamp: Date.now(),
},
],
}
}
@@ -1803,6 +1810,12 @@ async function* parseSSEStream(
}
}
// Auth/usage assistant response messages for Copilot
const COPILOT_AUTH_REQUIRED_MESSAGE =
'*Authorization failed. An API key must be configured in order to use the copilot. You can configure an api key in the settings page on sim.ai*'
const COPILOT_USAGE_EXCEEDED_MESSAGE =
'*Usage limit exceeded, please upgrade your plan to continue using the copilot*'
/**
* Copilot store using the new unified API
*/
@@ -2249,7 +2262,28 @@ export const useCopilotStore = create<CopilotStore>()(
logger.info('Message sending was aborted by user')
return // Don't throw or update state, abort handler already did
}
throw new Error(result.error || 'Failed to send message')
// Handle specific upstream statuses
let displayError = result.error || 'Failed to send message'
if (result.status === 401) {
displayError = COPILOT_AUTH_REQUIRED_MESSAGE
} else if (result.status === 402) {
displayError = COPILOT_USAGE_EXCEEDED_MESSAGE
}
const errorMessage = createErrorMessage(streamingMessage.id, displayError)
// Show as a normal assistant response without global error for auth/usage cases
const isAuthOrUsage = result.status === 401 || result.status === 402
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === streamingMessage.id ? errorMessage : msg
),
error: isAuthOrUsage ? null : displayError,
isSendingMessage: false,
abortController: null,
}))
}
} catch (error) {
// Check if this was an abort
@@ -2504,7 +2538,25 @@ export const useCopilotStore = create<CopilotStore>()(
logger.info('Implicit feedback sending was aborted by user')
return
}
throw new Error(result.error || 'Failed to send implicit feedback')
// Handle specific upstream statuses as normal assistant responses
let displayError = result.error || 'Failed to send implicit feedback'
if (result.status === 401) {
displayError = COPILOT_AUTH_REQUIRED_MESSAGE
} else if (result.status === 402) {
displayError = COPILOT_USAGE_EXCEEDED_MESSAGE
}
const errorMessage = createErrorMessage(newAssistantMessage.id, displayError)
const isAuthOrUsage = result.status === 401 || result.status === 402
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === newAssistantMessage.id ? errorMessage : msg
),
error: isAuthOrUsage ? null : displayError,
isSendingMessage: false,
abortController: null,
}))
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {