diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 95c5115cb2b..ca6012c7bb1 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -48,6 +48,8 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models # VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible) # VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth +# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible) +# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth # FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing # NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI. # AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED) @@ -60,6 +62,15 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # COHERE_API_KEY= # Cohere API key for the Knowledge block reranker (rerank-v4.0-pro/-fast, rerank-v3.5). Alternatively set COHERE_API_KEY_1/2/3 for rotation. # NEXT_PUBLIC_COHERE_CONFIGURED=true # Set when COHERE_API_KEY (or rotation keys) are pre-configured above. Hides the Cohere API Key field on the Knowledge block UI. +# Hosted tool API keys (Optional - lets Sim supply the key so users don't have to bring their own). +# Each provider reads `{PREFIX}_COUNT` then `{PREFIX}_1..N`, distributing requests round-robin across the keys. +# HUNTER_API_KEY_COUNT=2 # Number of Hunter.io keys for hosted Hunter blocks +# HUNTER_API_KEY_1= # Hunter.io API key #1 +# HUNTER_API_KEY_2= # Hunter.io API key #2 +# PEOPLEDATALABS_API_KEY_COUNT=2 # Number of People Data Labs keys for hosted PDL blocks +# PEOPLEDATALABS_API_KEY_1= # People Data Labs API key #1 +# PEOPLEDATALABS_API_KEY_2= # People Data Labs API key #2 + # Admin API (Optional - for self-hosted GitOps) # ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import. # Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index 41ff9ec4bbf..55d8f5acad0 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -12,13 +12,17 @@ import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { readFilePreviewSessions } from '@/lib/copilot/request/session' import { readEvents } from '@/lib/copilot/request/session/buffer' import { toStreamBatchEvent } from '@/lib/copilot/request/session/types' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatAPI') @@ -196,6 +200,9 @@ export async function GET(req: NextRequest) { chats: chats.map(transformChatListItem), }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error fetching copilot chats:', error) return createInternalServerErrorResponse('Failed to fetch chats') } diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 0aecdb462b9..05e4c7773db 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -10,12 +10,16 @@ import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { taskPubSub } from '@/lib/copilot/tasks' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatsListAPI') @@ -138,6 +142,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, id: result.chatId }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error creating workflow copilot chat:', error) return createInternalServerErrorResponse('Failed to create chat') } diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 5fa058736d2..92dbaa87ef2 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1132,7 +1132,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { output: { result: null, stdout: cleanStdout(shellStdout), executionTime }, }, routeContext, - { status: 500 } + { status: 422 } ) } @@ -1269,7 +1269,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { output: { result: null, stdout: cleanedOutput, executionTime }, }, routeContext, - { status: 500 } + { status: 422 } ) } @@ -1356,7 +1356,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { output: { result: null, stdout: cleanedOutput, executionTime }, }, routeContext, - { status: 500 } + { status: 422 } ) } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 8cea0668228..1509f68eb55 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -11,6 +11,7 @@ import { fetchGo } from '@/lib/copilot/request/go/fetch' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createNotFoundResponse, createUnauthorizedResponse, @@ -21,7 +22,10 @@ import { taskPubSub } from '@/lib/copilot/tasks' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('ForkChatAPI') @@ -150,6 +154,9 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true, id: newId }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error forking chat:', error) return createInternalServerErrorResponse('Failed to fork chat') } diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index f6d2d9eae35..1b7157fdde5 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -11,13 +11,17 @@ import { parseRequest } from '@/lib/api/server' import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' import { authenticateCopilotRequestSessionOnly, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { taskPubSub } from '@/lib/copilot/tasks' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipChatsAPI') @@ -68,6 +72,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, data: reconciled }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error fetching mothership chats:', error) return createInternalServerErrorResponse('Failed to fetch chats') } @@ -118,6 +125,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, id: chat.id }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error creating mothership chat:', error) return createInternalServerErrorResponse('Failed to create chat') } diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index ab53f413baf..a3550718b92 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -19,6 +19,7 @@ import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtim import { assertActiveWorkspaceAccess, getUserEntityPermissions, + isWorkspaceAccessDeniedError, } from '@/lib/workspaces/permissions/utils' export const maxDuration = 3600 @@ -378,6 +379,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Mothership execution aborted' }, { status: 499 }) } + if (isWorkspaceAccessDeniedError(error)) { + return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 }) + } + logger.error( messageId ? `Mothership execute error [messageId:${messageId}]` : 'Mothership execute error', { diff --git a/apps/sim/app/api/providers/litellm/models/route.ts b/apps/sim/app/api/providers/litellm/models/route.ts new file mode 100644 index 00000000000..bf40b54c424 --- /dev/null +++ b/apps/sim/app/api/providers/litellm/models/route.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + providerModelsResponseSchema, + vllmUpstreamResponseSchema, +} from '@/lib/api/contracts/providers' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' + +const logger = createLogger('LiteLLMModelsAPI') + +export const GET = withRouteHandler(async (_request: NextRequest) => { + if (isProviderBlacklisted('litellm')) { + logger.info('LiteLLM provider is blacklisted, returning empty models') + return NextResponse.json({ models: [] }) + } + + const baseUrl = (env.LITELLM_BASE_URL || '').replace(/\/$/, '') + + if (!baseUrl) { + logger.info('LITELLM_BASE_URL not configured') + return NextResponse.json({ models: [] }) + } + + try { + logger.info('Fetching LiteLLM models', { baseUrl }) + + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (env.LITELLM_API_KEY) { + headers.Authorization = `Bearer ${env.LITELLM_API_KEY}` + } + + const response = await fetch(`${baseUrl}/v1/models`, { + headers, + next: { revalidate: 60 }, + }) + + if (!response.ok) { + logger.warn('LiteLLM service is not available', { + status: response.status, + statusText: response.statusText, + }) + return NextResponse.json({ models: [] }) + } + + const data = vllmUpstreamResponseSchema.parse(await response.json()) + const allModels = data.data.map((model) => `litellm/${model.id}`) + const models = filterBlacklistedModels(allModels) + + logger.info('Successfully fetched LiteLLM models', { + count: models.length, + filtered: allModels.length - models.length, + models, + }) + + return NextResponse.json(providerModelsResponseSchema.parse({ models })) + } catch (error) { + logger.error('Failed to fetch LiteLLM models', { + error: getErrorMessage(error, 'Unknown error'), + baseUrl, + }) + + return NextResponse.json({ models: [] }) + } +}) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index f95b4a9941d..61648a2a4d9 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -19,7 +19,10 @@ import { } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -352,6 +355,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return NextResponse.json( + { success: false, error: 'Workspace access denied' }, + { status: 403 } + ) + } const message = getErrorMessage(error, 'Unknown error') logger.error('File operation failed', { operation: body.operation, error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 7af1ea24fdd..afb38a87842 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -1071,16 +1071,24 @@ const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) }) function SvgPreview({ content }: { content: string }) { - const wrappedContent = `${content}` + const [blobUrl, setBlobUrl] = useState('') + + useEffect(() => { + const url = URL.createObjectURL(new Blob([content], { type: 'image/svg+xml' })) + setBlobUrl(url) + return () => URL.revokeObjectURL(url) + }, [content]) return ( -