import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executeProviderContract } from '@/lib/api/contracts/providers' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { getServiceAccountToken, refreshTokenIfNeeded, resolveOAuthAccountId, } from '@/app/api/auth/oauth/utils' import { assertPermissionsAllowed, IntegrationNotAllowedError, ModelNotAllowedError, ProviderNotAllowedError, } from '@/ee/access-control/utils/permission-check' import type { StreamingExecution } from '@/executor/types' import { executeProviderRequest } from '@/providers' const logger = createLogger('ProvidersAPI') export const dynamic = 'force-dynamic' /** * Server-side proxy for provider requests */ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const startTime = Date.now() try { const auth = await checkInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } logger.info(`[${requestId}] Provider API request started`, { timestamp: new Date().toISOString(), userAgent: request.headers.get('User-Agent'), contentType: request.headers.get('Content-Type'), }) const validation = await parseRequest( executeProviderContract, request, {}, { validationErrorResponse: () => NextResponse.json({ error: 'Invalid request body' }, { status: 400 }), invalidJsonResponse: () => NextResponse.json({ error: 'Invalid request body' }, { status: 400 }), } ) if (!validation.success) return validation.response const body = validation.data.body const { provider, model, systemPrompt, context, tools, temperature, maxTokens, apiKey, azureEndpoint, azureApiVersion, vertexProject, vertexLocation, vertexCredential, bedrockAccessKeyId, bedrockSecretKey, bedrockRegion, responseFormat, workflowId, workspaceId, stream, messages, environmentVariables, workflowVariables, blockData, blockNameMapping, reasoningEffort, verbosity, } = body logger.info(`[${requestId}] Provider request details`, { provider, model, hasSystemPrompt: !!systemPrompt, hasContext: !!context, hasTools: !!tools?.length, toolCount: tools?.length || 0, hasApiKey: !!apiKey, hasAzureEndpoint: !!azureEndpoint, hasAzureApiVersion: !!azureApiVersion, hasVertexProject: !!vertexProject, hasVertexLocation: !!vertexLocation, hasVertexCredential: !!vertexCredential, hasBedrockAccessKeyId: !!bedrockAccessKeyId, hasBedrockSecretKey: !!bedrockSecretKey, hasBedrockRegion: !!bedrockRegion, hasResponseFormat: !!responseFormat, workflowId, stream: !!stream, hasMessages: !!messages?.length, messageCount: messages?.length || 0, hasEnvironmentVariables: !!environmentVariables && Object.keys(environmentVariables).length > 0, hasWorkflowVariables: !!workflowVariables && Object.keys(workflowVariables).length > 0, reasoningEffort, verbosity, }) if (workspaceId) { const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId) if (!workspaceAccess.hasAccess) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } try { await assertPermissionsAllowed({ userId: auth.userId, workspaceId, model, }) } catch (err) { if ( err instanceof ProviderNotAllowedError || err instanceof ModelNotAllowedError || err instanceof IntegrationNotAllowedError ) { return NextResponse.json({ error: err.message }, { status: 403 }) } throw err } } let finalApiKey: string | undefined = apiKey try { if (provider === 'vertex' && vertexCredential) { const vertexCredAccess = await authorizeCredentialUse(request, { credentialId: vertexCredential, workflowId: workflowId || undefined, requireWorkflowIdForInternal: false, }) if (!vertexCredAccess.ok) { logger.warn(`[${requestId}] Vertex credential access denied`, { error: vertexCredAccess.error, credentialId: vertexCredential, }) return NextResponse.json( { error: vertexCredAccess.error || 'Unauthorized' }, { status: 401 } ) } finalApiKey = await resolveVertexCredential(requestId, vertexCredential) } } catch (error) { logger.error(`[${requestId}] Failed to resolve Vertex credential:`, { provider, model, error: toError(error).message, hasVertexCredential: !!vertexCredential, }) return NextResponse.json( { error: getErrorMessage(error, 'Credential error') }, { status: 400 } ) } logger.info(`[${requestId}] Executing provider request`, { provider, model, workflowId, hasApiKey: !!finalApiKey, }) const response = await executeProviderRequest(provider, { model, systemPrompt, context, tools, temperature, maxTokens, apiKey: finalApiKey, azureEndpoint, azureApiVersion, vertexProject, vertexLocation, bedrockAccessKeyId, bedrockSecretKey, bedrockRegion, responseFormat, workflowId, workspaceId, userId: auth.userId, stream, messages, environmentVariables, workflowVariables, blockData, blockNameMapping, reasoningEffort, verbosity, }) const executionTime = Date.now() - startTime logger.info(`[${requestId}] Provider request completed successfully`, { provider, model, workflowId, executionTime, responseType: response instanceof ReadableStream ? 'stream' : response && typeof response === 'object' && 'stream' in response ? 'streaming-execution' : 'json', }) // Check if the response is a StreamingExecution if ( response && typeof response === 'object' && 'stream' in response && 'execution' in response ) { const streamingExec = response as StreamingExecution logger.info(`[${requestId}] Received StreamingExecution from provider`) // Extract the stream and execution data const stream = streamingExec.stream const executionData = streamingExec.execution // Attach the execution data as a custom header // We need to safely serialize the execution data to avoid circular references let executionDataHeader try { // Create a safe version of execution data with the most important fields const safeExecutionData = { success: executionData.success, output: { // Sanitize content to remove non-ASCII characters that would cause ByteString errors content: executionData.output?.content ? String(executionData.output.content).replace(/[\u0080-\uFFFF]/g, '') : '', model: executionData.output?.model, tokens: executionData.output?.tokens || { input: 0, output: 0, total: 0, }, // Sanitize any potential Unicode characters in tool calls toolCalls: executionData.output?.toolCalls ? sanitizeToolCalls(executionData.output.toolCalls) : undefined, providerTiming: executionData.output?.providerTiming, cost: executionData.output?.cost, }, error: executionData.error, logs: [], // Strip logs from header to avoid encoding issues metadata: { startTime: executionData.metadata?.startTime, endTime: executionData.metadata?.endTime, duration: executionData.metadata?.duration, }, isStreaming: true, // Always mark streaming execution data as streaming blockId: executionData.logs?.[0]?.blockId, blockName: executionData.logs?.[0]?.blockName, blockType: executionData.logs?.[0]?.blockType, } executionDataHeader = JSON.stringify(safeExecutionData) } catch (error) { logger.error(`[${requestId}] Failed to serialize execution data:`, error) executionDataHeader = JSON.stringify({ success: executionData.success, error: 'Failed to serialize full execution data', }) } // Return the stream with execution data in a header return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Execution-Data': executionDataHeader, }, }) } // Check if the response is a ReadableStream for streaming if (response instanceof ReadableStream) { logger.info(`[${requestId}] Streaming response from provider`) return new Response(response, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }) } // Return regular JSON response for non-streaming return NextResponse.json(response) } catch (error) { const executionTime = Date.now() - startTime logger.error(`[${requestId}] Provider request failed:`, { error: toError(error).message, errorName: error instanceof Error ? error.name : 'Unknown', errorStack: error instanceof Error ? error.stack : undefined, executionTime, timestamp: new Date().toISOString(), }) return NextResponse.json({ error: toError(error).message }, { status: 500 }) } }) /** * Helper function to sanitize tool calls to remove Unicode characters */ function sanitizeToolCalls(toolCalls: any) { // If it's an object with a list property, sanitize the list if (toolCalls && typeof toolCalls === 'object' && Array.isArray(toolCalls.list)) { return { ...toolCalls, list: toolCalls.list.map(sanitizeToolCall), } } // If it's an array, sanitize each item if (Array.isArray(toolCalls)) { return toolCalls.map(sanitizeToolCall) } return toolCalls } /** * Sanitize a single tool call to remove Unicode characters */ function sanitizeToolCall(toolCall: any) { if (!toolCall || typeof toolCall !== 'object') return toolCall // Create a sanitized copy const sanitized = { ...toolCall } // Sanitize any string fields that might contain Unicode if (typeof sanitized.name === 'string') { sanitized.name = sanitized.name.replace(/[\u0080-\uFFFF]/g, '') } // Sanitize input/arguments if (sanitized.input && typeof sanitized.input === 'object') { sanitized.input = sanitizeObject(sanitized.input) } if (sanitized.arguments && typeof sanitized.arguments === 'object') { sanitized.arguments = sanitizeObject(sanitized.arguments) } // Sanitize output/result if (sanitized.output && typeof sanitized.output === 'object') { sanitized.output = sanitizeObject(sanitized.output) } if (sanitized.result && typeof sanitized.result === 'object') { sanitized.result = sanitizeObject(sanitized.result) } // Sanitize error message if (typeof sanitized.error === 'string') { sanitized.error = sanitized.error.replace(/[\u0080-\uFFFF]/g, '') } return sanitized } /** * Recursively sanitize an object to remove Unicode characters from strings */ function sanitizeObject(obj: any): any { if (!obj || typeof obj !== 'object') return obj // Handle arrays if (Array.isArray(obj)) { return obj.map((item) => sanitizeObject(item)) } // Handle objects const result: any = {} for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string') { result[key] = value.replace(/[\u0080-\uFFFF]/g, '') } else if (typeof value === 'object' && value !== null) { result[key] = sanitizeObject(value) } else { result[key] = value } } return result } /** * Resolves a Vertex AI OAuth credential to an access token */ async function resolveVertexCredential(requestId: string, credentialId: string): Promise { logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { throw new Error(`Vertex AI credential not found: ${credentialId}`) } if (resolved.credentialType === 'service_account' && resolved.credentialId) { const accessToken = await getServiceAccountToken(resolved.credentialId, [ 'https://www.googleapis.com/auth/cloud-platform', ]) logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`) return accessToken } const credential = await db.query.account.findFirst({ where: eq(account.id, resolved.accountId), }) if (!credential) { throw new Error(`Vertex AI credential not found: ${credentialId}`) } const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId) if (!accessToken) { throw new Error('Failed to get Vertex AI access token') } logger.info(`[${requestId}] Successfully resolved Vertex AI credential`) return accessToken }