chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
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<string> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user