From d62f9cabde99c07e9a911845d6893aafb38cab72 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 26 May 2026 10:10:09 -0700 Subject: [PATCH 01/10] feat(zoom): add KB connector for cloud recording transcripts, fix refresh token rotation (#4735) * feat(zoom): add KB connector for cloud recording transcripts, fix refresh token rotation * fix(zoom): trim maxRecordings within page, relax VTT cue-id parsing * fix(zoom): widen incremental sync overlap to 30 days for late transcripts --- apps/sim/connectors/registry.ts | 2 + apps/sim/connectors/zoom/index.ts | 1 + apps/sim/connectors/zoom/zoom.ts | 519 ++++++++++++++++++++++++++++++ apps/sim/lib/oauth/oauth.ts | 2 +- 4 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 apps/sim/connectors/zoom/index.ts create mode 100644 apps/sim/connectors/zoom/zoom.ts diff --git a/apps/sim/connectors/registry.ts b/apps/sim/connectors/registry.ts index 284f20bc522..4e87861bde6 100644 --- a/apps/sim/connectors/registry.ts +++ b/apps/sim/connectors/registry.ts @@ -29,6 +29,7 @@ import type { ConnectorRegistry } from '@/connectors/types' import { webflowConnector } from '@/connectors/webflow' import { wordpressConnector } from '@/connectors/wordpress' import { zendeskConnector } from '@/connectors/zendesk' +import { zoomConnector } from '@/connectors/zoom' export const CONNECTOR_REGISTRY: ConnectorRegistry = { airtable: airtableConnector, @@ -61,4 +62,5 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { webflow: webflowConnector, wordpress: wordpressConnector, zendesk: zendeskConnector, + zoom: zoomConnector, } diff --git a/apps/sim/connectors/zoom/index.ts b/apps/sim/connectors/zoom/index.ts new file mode 100644 index 00000000000..668411f0deb --- /dev/null +++ b/apps/sim/connectors/zoom/index.ts @@ -0,0 +1 @@ +export { zoomConnector } from '@/connectors/zoom/zoom' diff --git a/apps/sim/connectors/zoom/zoom.ts b/apps/sim/connectors/zoom/zoom.ts new file mode 100644 index 00000000000..80277548925 --- /dev/null +++ b/apps/sim/connectors/zoom/zoom.ts @@ -0,0 +1,519 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { ZoomIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { parseTagDate } from '@/connectors/utils' + +const logger = createLogger('ZoomConnector') + +const ZOOM_API_BASE = 'https://api.zoom.us/v2' +const PAGE_SIZE = 300 +const WINDOW_DAYS = 30 +const DEFAULT_LOOKBACK_DAYS = 180 +const MAX_LOOKBACK_DAYS = 180 +/** + * Days of overlap added when computing the incremental sync window. Zoom transcript + * generation is usually fast, but AI Companion / audio transcription can lag hours to + * days for large accounts. A 30-day overlap catches late-arriving transcripts at the + * cost of at most one extra 30-day window per sync. + */ +const INCREMENTAL_OVERLAP_DAYS = 30 +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +interface ZoomRecordingFile { + id?: string + meeting_id?: string + recording_start?: string + recording_end?: string + file_type?: string + file_extension?: string + file_size?: number + download_url?: string + status?: string + recording_type?: string +} + +interface ZoomRecording { + uuid: string + id?: number | string + topic?: string + start_time?: string + duration?: number + total_size?: number + recording_count?: number + share_url?: string + host_email?: string + host_id?: string + account_id?: string + type?: number + recording_files?: ZoomRecordingFile[] +} + +interface ZoomRecordingsListResponse { + meetings?: ZoomRecording[] + next_page_token?: string + page_size?: number + total_records?: number + from?: string + to?: string +} + +interface CursorState { + windowIndex: number + pageToken?: string +} + +/** + * URL-encodes a Zoom meeting UUID. Double-encodes when the UUID starts with '/' + * or contains '//', per Zoom's API requirements. + */ +function encodeMeetingUuid(uuid: string): string { + const encoded = encodeURIComponent(uuid) + if (uuid.startsWith('/') || uuid.includes('//')) { + return encodeURIComponent(encoded) + } + return encoded +} + +function formatDate(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +function encodeCursor(state: CursorState): string { + return Buffer.from(JSON.stringify(state), 'utf8').toString('base64url') +} + +function decodeCursor(cursor?: string): CursorState { + if (!cursor) return { windowIndex: 0 } + try { + const json = Buffer.from(cursor, 'base64url').toString('utf8') + const parsed = JSON.parse(json) as Partial + return { + windowIndex: Number(parsed.windowIndex) || 0, + pageToken: typeof parsed.pageToken === 'string' ? parsed.pageToken : undefined, + } + } catch { + return { windowIndex: 0 } + } +} + +/** + * Picks the best transcript file from a recording's files array. + * Prefers the AI Companion audio_transcript (file_type TRANSCRIPT) and falls back + * to closed captions (file_type CC) — both are VTT and contain spoken text. + */ +function findTranscriptFile(files?: ZoomRecordingFile[]): ZoomRecordingFile | undefined { + if (!files) return undefined + const eligible = (f: ZoomRecordingFile) => + Boolean(f.download_url) && (f.status === 'completed' || f.status == null) + + const transcript = files.find((f) => f.file_type === 'TRANSCRIPT' && eligible(f)) + if (transcript) return transcript + return files.find((f) => f.file_type === 'CC' && eligible(f)) +} + +/** + * Extracts spoken text from a Zoom WebVTT transcript, stripping cue identifiers, + * timestamps, and inline markup. Handles both Zoom's `Speaker: text` convention + * and standard WebVTT `text` voice tags. + */ +function parseVtt(vtt: string): string { + const lines = vtt.split(/\r?\n/) + const segments: string[] = [] + let i = 0 + + while (i < lines.length && lines[i].trim() !== '') i++ + + while (i < lines.length) { + while (i < lines.length && lines[i].trim() === '') i++ + if (i >= lines.length) break + + if (i + 1 < lines.length && !lines[i].includes('-->') && lines[i + 1].includes('-->')) { + i++ + } + + if (i < lines.length && lines[i].includes('-->')) { + i++ + } else { + while (i < lines.length && lines[i].trim() !== '') i++ + continue + } + + const textParts: string[] = [] + while (i < lines.length && lines[i].trim() !== '') { + textParts.push(lines[i]) + i++ + } + + if (textParts.length > 0) { + const raw = textParts.join(' ') + const withSpeakers = raw.replace(/]+)?\s+([^>]+)>([\s\S]*?)<\/v>/g, '$1: $2') + const stripped = withSpeakers + .replace(/<\/?[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + if (stripped) segments.push(stripped) + } + } + + return segments.join('\n') +} + +function formatTranscriptContent(recording: ZoomRecording, transcript: string): string { + const parts: string[] = [] + if (recording.topic) parts.push(`Meeting: ${recording.topic}`) + if (recording.start_time) parts.push(`Date: ${recording.start_time}`) + if (recording.duration != null) parts.push(`Duration: ${recording.duration} minutes`) + if (recording.host_email) parts.push(`Host: ${recording.host_email}`) + + parts.push('') + parts.push('--- Transcript ---') + parts.push(transcript) + + return parts.join('\n') +} + +function buildContentHash(recording: ZoomRecording, file: ZoomRecordingFile): string { + return `zoom:${recording.uuid}:${file.id ?? ''}:${file.file_size ?? ''}:${file.recording_end ?? ''}` +} + +function buildSourceUrl(recording: ZoomRecording): string | undefined { + return recording.share_url || undefined +} + +function recordingToStub( + recording: ZoomRecording, + transcriptFile: ZoomRecordingFile +): ExternalDocument { + return { + externalId: recording.uuid, + title: recording.topic?.trim() || 'Untitled Zoom Meeting', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: buildSourceUrl(recording), + contentHash: buildContentHash(recording, transcriptFile), + metadata: { + meetingId: recording.id != null ? String(recording.id) : undefined, + hostEmail: recording.host_email, + duration: recording.duration, + meetingDate: recording.start_time, + topic: recording.topic, + }, + } +} + +/** + * Computes the effective lookback window in days, narrowing to the time since + * the last successful sync (plus an overlap to catch transcripts that finished + * processing late) when incremental sync is active. + */ +function computeLookbackDays( + sourceConfig: Record, + lastSyncAt: Date | undefined +): number { + const raw = sourceConfig.lookback as string | undefined + const configured = Number(raw) + const baseline = + Number.isFinite(configured) && configured > 0 + ? Math.min(Math.floor(configured), MAX_LOOKBACK_DAYS) + : DEFAULT_LOOKBACK_DAYS + + if (!lastSyncAt) return baseline + + const sinceLastSync = Math.ceil((Date.now() - lastSyncAt.getTime()) / MS_PER_DAY) + const incremental = Math.max(sinceLastSync + INCREMENTAL_OVERLAP_DAYS, INCREMENTAL_OVERLAP_DAYS) + return Math.min(incremental, baseline) +} + +export const zoomConnector: ConnectorConfig = { + id: 'zoom', + name: 'Zoom', + description: 'Sync meeting transcripts from Zoom cloud recordings', + version: '1.0.0', + icon: ZoomIcon, + + auth: { + mode: 'oauth', + provider: 'zoom', + requiredScopes: [ + 'user:read:user', + 'cloud_recording:read:list_user_recordings', + 'cloud_recording:read:list_recording_files', + ], + }, + + supportsIncrementalSync: true, + + configFields: [ + { + id: 'lookback', + title: 'Date Range', + type: 'dropdown', + required: false, + options: [ + { label: 'Last 30 days', id: '30' }, + { label: 'Last 90 days', id: '90' }, + { label: 'Last 6 months (recommended)', id: '180' }, + ], + description: + 'On initial sync only. Zoom only allows access to cloud recordings within the last 6 months.', + }, + { + id: 'maxRecordings', + title: 'Max Recordings', + type: 'short-input', + required: false, + placeholder: 'e.g. 200 (default: unlimited)', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record, + lastSyncAt?: Date + ): Promise => { + const lookbackDays = computeLookbackDays(sourceConfig, lastSyncAt) + const maxRecordings = sourceConfig.maxRecordings ? Number(sourceConfig.maxRecordings) : 0 + const numWindows = Math.max(1, Math.ceil(lookbackDays / WINDOW_DAYS)) + const state = decodeCursor(cursor) + + if (state.windowIndex >= numWindows) { + return { documents: [], hasMore: false } + } + + const now = new Date() + const earliest = new Date(now.getTime() - lookbackDays * MS_PER_DAY) + const toDate = new Date(now.getTime() - state.windowIndex * WINDOW_DAYS * MS_PER_DAY) + const rawFromDate = new Date(toDate.getTime() - WINDOW_DAYS * MS_PER_DAY) + const fromDate = rawFromDate < earliest ? earliest : rawFromDate + + if (fromDate >= toDate) { + return { documents: [], hasMore: false } + } + + const queryParams = new URLSearchParams({ + page_size: String(PAGE_SIZE), + from: formatDate(fromDate), + to: formatDate(toDate), + trash: 'false', + }) + if (state.pageToken) queryParams.set('next_page_token', state.pageToken) + + const url = `${ZOOM_API_BASE}/users/me/recordings?${queryParams.toString()}` + + logger.info('Listing Zoom recordings', { + windowIndex: state.windowIndex, + windowTotal: numWindows, + from: formatDate(fromDate), + to: formatDate(toDate), + hasToken: Boolean(state.pageToken), + incremental: Boolean(lastSyncAt), + }) + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to list Zoom recordings', { + status: response.status, + error: errorText.slice(0, 500), + }) + throw new Error(`Failed to list Zoom recordings: ${response.status}`) + } + + const data = (await response.json()) as ZoomRecordingsListResponse + const meetings = data.meetings ?? [] + const nextPageToken = data.next_page_token?.trim() || undefined + + const allDocuments: ExternalDocument[] = [] + for (const meeting of meetings) { + if (!meeting.uuid) continue + const transcript = findTranscriptFile(meeting.recording_files) + if (!transcript) continue + allDocuments.push(recordingToStub(meeting, transcript)) + } + + const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0 + let documents = allDocuments + if (maxRecordings > 0) { + const remaining = Math.max(0, maxRecordings - prevFetched) + if (allDocuments.length > remaining) { + documents = allDocuments.slice(0, remaining) + } + } + + const totalFetched = prevFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxRecordings > 0 && totalFetched >= maxRecordings + if (hitLimit && syncContext) syncContext.listingCapped = true + + let nextCursor: string | undefined + let hasMore = false + + if (hitLimit) { + // Stop syncing — limit reached + } else if (nextPageToken) { + nextCursor = encodeCursor({ windowIndex: state.windowIndex, pageToken: nextPageToken }) + hasMore = true + } else if (state.windowIndex + 1 < numWindows) { + nextCursor = encodeCursor({ windowIndex: state.windowIndex + 1 }) + hasMore = true + } + + return { documents, nextCursor, hasMore } + }, + + getDocument: async ( + accessToken: string, + _sourceConfig: Record, + externalId: string + ): Promise => { + try { + if (!externalId) return null + + const url = `${ZOOM_API_BASE}/meetings/${encodeMeetingUuid(externalId)}/recordings` + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + if (response.status === 404 || response.status === 410) return null + throw new Error(`Failed to fetch Zoom recording: ${response.status}`) + } + + const recording = (await response.json()) as ZoomRecording + const transcript = findTranscriptFile(recording.recording_files) + + if (!transcript?.download_url) { + logger.info('Transcript no longer available for Zoom recording', { externalId }) + return null + } + + const vttResponse = await fetchWithRetry(transcript.download_url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!vttResponse.ok) { + logger.warn('Failed to download Zoom transcript', { + externalId, + status: vttResponse.status, + }) + return null + } + + const vttText = await vttResponse.text() + const transcriptText = parseVtt(vttText).trim() + if (!transcriptText) return null + + const content = formatTranscriptContent(recording, transcriptText) + + return { + externalId: recording.uuid || externalId, + title: recording.topic?.trim() || 'Untitled Zoom Meeting', + content, + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: buildSourceUrl(recording), + contentHash: buildContentHash(recording, transcript), + metadata: { + meetingId: recording.id != null ? String(recording.id) : undefined, + hostEmail: recording.host_email, + duration: recording.duration, + meetingDate: recording.start_time, + topic: recording.topic, + }, + } + } catch (error) { + logger.warn('Failed to get Zoom recording', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const maxRecordings = sourceConfig.maxRecordings as string | undefined + if (maxRecordings && (Number.isNaN(Number(maxRecordings)) || Number(maxRecordings) < 0)) { + return { valid: false, error: 'Max recordings must be a non-negative number' } + } + + try { + const response = await fetchWithRetry( + `${ZOOM_API_BASE}/users/me`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + return { + valid: false, + error: `Zoom access failed: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, + } + } + + return { valid: true } + } catch (error) { + const message = getErrorMessage(error, 'Failed to validate configuration') + return { valid: false, error: message } + } + }, + + tagDefinitions: [ + { id: 'topic', displayName: 'Topic', fieldType: 'text' }, + { id: 'hostEmail', displayName: 'Host Email', fieldType: 'text' }, + { id: 'duration', displayName: 'Duration (minutes)', fieldType: 'number' }, + { id: 'meetingDate', displayName: 'Meeting Date', fieldType: 'date' }, + ], + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.topic === 'string' && metadata.topic.trim()) { + result.topic = metadata.topic + } + + if (typeof metadata.hostEmail === 'string' && metadata.hostEmail.trim()) { + result.hostEmail = metadata.hostEmail + } + + if (metadata.duration != null) { + const num = Number(metadata.duration) + if (!Number.isNaN(num)) result.duration = num + } + + const meetingDate = parseTagDate(metadata.meetingDate) + if (meetingDate) result.meetingDate = meetingDate + + return result + }, +} diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 82c3ee7bb1e..21d12053ebd 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1367,7 +1367,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { clientId, clientSecret, useBasicAuth: true, - supportsRefreshTokenRotation: false, + supportsRefreshTokenRotation: true, } } case 'wordpress': { From 81bf93b184c963caa1768d9da2e16ac1ac29a41c Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 26 May 2026 10:38:24 -0700 Subject: [PATCH 02/10] feat(litellm): add LiteLLM as AI gateway provider (#4739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add LiteLLM as AI gateway provider * fix: add litellm to attachments, provider store, utils, and block guards * fix: add frontend model discovery pipeline for litellm provider Add API route, contract, query hook case, and ProviderModelsLoader entry so litellm models are fetched and synced to the store on workspace load, matching the vllm/ollama/openrouter/fireworks pattern. Also fixes defaultModel to empty string and adds litellm/ prefix early-return in blocks/utils.ts (reviewer feedback). * fix: remove azureEndpoint fallback from LiteLLM provider Copy-paste artifact from vLLM provider. LiteLLM should only use LITELLM_BASE_URL, not fall back to azureEndpoint which could cause requests to be routed to the wrong server. * fix(litellm): close audit gaps from PR #4644 - byok.ts: add litellm branch to getApiKeyWithBYOK so workflow block execution can resolve the proxy key instead of throwing "API key is required for litellm ..." - check-api-validation-contracts.ts: bump route baseline 755 -> 756 to account for the new /api/providers/litellm/models route - .env.example: document LITELLM_BASE_URL / LITELLM_API_KEY - copilot edit-workflow validation: include LiteLLM in the list of user-configured prefixed providers shown to the model - providers/utils.ts: drop stray optional-chain on providers.litellm to match the vllm pattern - lint: apply biome formatting fixes (multi-line if, SVG path, multi-line DYNAMIC_MODEL_PROVIDERS) * fix(litellm): final parity gaps from second audit - blocks/utils.ts getModelOptions(): include litellm models in the combined model dropdown — was previously dropping any proxy-discovered models from the agent block model picker. - get-blocks-metadata-tool.ts mockProvidersState: add litellm bucket so the server-side copilot block-metadata fallback can render model options when the providers store is not initialized. - blocks/utils.test.ts: add litellm to mock providers state (initial + beforeEach reset) and add a parallel store-bucket guard test mirroring the vLLM case. - providers/utils.test.ts: add parallel getApiKey test for litellm. * feat(litellm): use official LiteLLM brand icon and color - icons.tsx: replace the placeholder letterform with the official LiteLLM brand mark embedded as a PNG data URI in an SVG image. - models.ts: set color: #040229 on the litellm provider definition to match the brand background. * chore(litellm): validate /v1/models response with shared schema in initialize() Match the API route handler — both code paths now run the same vllmUpstreamResponseSchema.parse() over the upstream /v1/models JSON instead of a raw type-cast, so malformed upstream payloads surface a descriptive ZodError instead of a downstream TypeError. Addresses Greptile review feedback on PR #4739. --------- Co-authored-by: RheagalFire --- apps/sim/.env.example | 2 + .../app/api/providers/litellm/models/route.ts | 70 ++ .../providers/provider-models-loader.tsx | 4 + apps/sim/blocks/utils.test.ts | 7 + apps/sim/blocks/utils.ts | 7 +- apps/sim/components/icons.tsx | 14 + apps/sim/hooks/queries/providers.ts | 3 + apps/sim/lib/api-key/byok.ts | 6 + apps/sim/lib/api/contracts/providers.ts | 9 + .../server/blocks/get-blocks-metadata-tool.ts | 1 + .../workflow/edit-workflow/validation.ts | 2 +- apps/sim/lib/core/config/env.ts | 2 + apps/sim/providers/attachments.ts | 4 + apps/sim/providers/litellm/index.ts | 688 ++++++++++++++++++ apps/sim/providers/litellm/utils.ts | 14 + apps/sim/providers/models.ts | 37 +- apps/sim/providers/registry.ts | 2 + apps/sim/providers/types.ts | 1 + apps/sim/providers/utils.test.ts | 13 + apps/sim/providers/utils.ts | 14 + apps/sim/stores/providers/store.ts | 1 + apps/sim/stores/providers/types.ts | 2 +- scripts/check-api-validation-contracts.ts | 4 +- 23 files changed, 899 insertions(+), 8 deletions(-) create mode 100644 apps/sim/app/api/providers/litellm/models/route.ts create mode 100644 apps/sim/providers/litellm/index.ts create mode 100644 apps/sim/providers/litellm/utils.ts diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 95c5115cb2b..e924dd42241 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) 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/workspace/[workspaceId]/providers/provider-models-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx index f83d9e63bb0..f2563a2b37c 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx @@ -6,6 +6,7 @@ import { useParams } from 'next/navigation' import { useProviderModels } from '@/hooks/queries/providers' import { updateFireworksProviderModels, + updateLiteLLMProviderModels, updateOllamaProviderModels, updateOpenRouterProviderModels, updateVLLMProviderModels, @@ -32,6 +33,8 @@ function useSyncProvider(provider: ProviderName, workspaceId?: string) { updateOllamaProviderModels(data.models) } else if (provider === 'vllm') { updateVLLMProviderModels(data.models) + } else if (provider === 'litellm') { + updateLiteLLMProviderModels(data.models) } else if (provider === 'openrouter') { void updateOpenRouterProviderModels(data.models) if (data.modelInfo) { @@ -61,6 +64,7 @@ export function ProviderModelsLoader() { useSyncProvider('base') useSyncProvider('ollama') useSyncProvider('vllm') + useSyncProvider('litellm') useSyncProvider('openrouter') useSyncProvider('fireworks', workspaceId) return null diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 309f5990474..3148e646732 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -27,6 +27,7 @@ const { mockProviders } = vi.hoisted(() => ({ base: { models: [] as string[], isLoading: false }, ollama: { models: [] as string[], isLoading: false }, vllm: { models: [] as string[], isLoading: false }, + litellm: { models: [] as string[], isLoading: false }, openrouter: { models: [] as string[], isLoading: false }, fireworks: { models: [] as string[], isLoading: false }, }, @@ -101,6 +102,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { base: { models: [], isLoading: false }, ollama: { models: [], isLoading: false }, vllm: { models: [], isLoading: false }, + litellm: { models: [], isLoading: false }, openrouter: { models: [], isLoading: false }, fireworks: { models: [], isLoading: false }, } @@ -185,6 +187,11 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { expect(evaluateCondition('my-custom-model')).toBe(false) }) + it('does not require API key when model is in the LiteLLM store bucket', () => { + mockProviders.value.litellm.models = ['litellm/anthropic/claude-sonnet-4-6'] + expect(evaluateCondition('litellm/anthropic/claude-sonnet-4-6')).toBe(false) + }) + it('requires API key when model is in the fireworks store bucket', () => { mockProviders.value.fireworks.models = ['fireworks/llama-3'] expect(evaluateCondition('fireworks/llama-3')).toBe(true) diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 830a4642e66..4a17b845263 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -51,6 +51,7 @@ export function getModelOptions() { const baseModels = providersState.providers.base.models const ollamaModels = providersState.providers.ollama.models const vllmModels = providersState.providers.vllm.models + const litellmModels = providersState.providers.litellm.models const openrouterModels = providersState.providers.openrouter.models const fireworksModels = providersState.providers.fireworks.models const allModels = Array.from( @@ -58,6 +59,7 @@ export function getModelOptions() { ...baseModels, ...ollamaModels, ...vllmModels, + ...litellmModels, ...openrouterModels, ...fireworksModels, ]) @@ -160,12 +162,13 @@ function shouldRequireApiKeyForModel(model: string): boolean { ) { return false } - if (normalizedModel.startsWith('vllm/')) { + if (normalizedModel.startsWith('vllm/') || normalizedModel.startsWith('litellm/')) { return false } const storeProvider = getProviderFromStore(normalizedModel) - if (storeProvider === 'ollama' || storeProvider === 'vllm') return false + if (storeProvider === 'ollama' || storeProvider === 'vllm' || storeProvider === 'litellm') + return false if (storeProvider) return true if (isOllamaConfigured) { diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 5f79d8ad05c..79835cf2720 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4439,6 +4439,20 @@ export function VllmIcon(props: SVGProps) { ) } +export function LitellmIcon(props: SVGProps) { + return ( + + LiteLLM + + + ) +} + export function PosthogIcon(props: SVGProps) { return ( m.id) }, ollama: { models: [] }, vllm: { models: [] }, + litellm: { models: [] }, openrouter: { models: [] }, fireworks: { models: [] }, }, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 2c182cc839c..e98e39a3967 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -369,7 +369,7 @@ export function validateValueForSubBlockType( blockType, field: fieldName, value, - error: `Unknown model id "${trimmed}" for block "${blockType}". Read components/blocks/${blockType}.json (the model.options array) for valid ids; prefer entries with recommended: true and avoid deprecated: true. For user-configured models (Ollama, vLLM, OpenRouter, Fireworks), prefix the id with the provider slash, e.g. "ollama/llama3.1:8b".${suggestionText}`, + error: `Unknown model id "${trimmed}" for block "${blockType}". Read components/blocks/${blockType}.json (the model.options array) for valid ids; prefer entries with recommended: true and avoid deprecated: true. For user-configured models (Ollama, vLLM, LiteLLM, OpenRouter, Fireworks), prefix the id with the provider slash, e.g. "ollama/llama3.1:8b".${suggestionText}`, }, } } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 41dc464ff7c..ecad5d3bb02 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -127,6 +127,8 @@ export const env = createEnv({ OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL VLLM_BASE_URL: z.string().url().optional(), // vLLM self-hosted base URL (OpenAI-compatible) VLLM_API_KEY: z.string().optional(), // Optional bearer token for vLLM + LITELLM_BASE_URL: z.string().url().optional(), // LiteLLM proxy base URL (OpenAI-compatible) + LITELLM_API_KEY: z.string().optional(), // Optional bearer token for LiteLLM FIREWORKS_API_KEY: z.string().optional(), // Optional Fireworks AI API key for model listing COHERE_API_KEY: z.string().min(1).optional(), // Cohere API key for reranker (rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5) COHERE_API_KEY_1: z.string().min(1).optional(), // Primary Cohere API key for rotation diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index 380b4d8c890..d1b5d48c828 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -24,6 +24,7 @@ export type AttachmentProvider = | 'fireworks' | 'ollama' | 'vllm' + | 'litellm' | 'xai' | 'deepseek' | 'cerebras' @@ -93,6 +94,7 @@ const PROVIDER_SUPPORTED_LABELS: Record = { fireworks: 'images through image_url message parts on vision models', ollama: 'images through image_url message parts on vision models', vllm: 'images through image_url message parts on multimodal models', + litellm: 'images through image_url message parts on multimodal models', xai: 'images through image_url message parts on Grok vision models', deepseek: 'no file attachments in the current API adapter', cerebras: 'no file attachments in the current API adapter', @@ -109,6 +111,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme if (providerId === 'fireworks') return 'fireworks' if (providerId === 'ollama') return 'ollama' if (providerId === 'vllm') return 'vllm' + if (providerId === 'litellm') return 'litellm' if (providerId === 'xai') return 'xai' if (providerId === 'deepseek') return 'deepseek' if (providerId === 'cerebras') return 'cerebras' @@ -247,6 +250,7 @@ function isMimeTypeSupportedByProvider( case 'fireworks': case 'ollama': case 'vllm': + case 'litellm': case 'xai': return isImageMimeType(mimeType) case 'deepseek': diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts new file mode 100644 index 00000000000..33e363f0509 --- /dev/null +++ b/apps/sim/providers/litellm/index.ts @@ -0,0 +1,688 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import OpenAI from 'openai' +import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' +import { env } from '@/lib/core/config/env' +import type { StreamingExecution } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' +import { createReadableStreamFromLiteLLMStream } from '@/providers/litellm/utils' +import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import type { + Message, + ProviderConfig, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { ProviderError } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + prepareToolsWithUsageControl, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { useProvidersStore } from '@/stores/providers' +import { executeTool } from '@/tools' + +const logger = createLogger('LiteLLMProvider') +const LITELLM_VERSION = '1.0.0' + +export const litellmProvider: ProviderConfig = { + id: 'litellm', + name: 'LiteLLM', + description: 'LiteLLM proxy with OpenAI-compatible API', + version: LITELLM_VERSION, + models: getProviderModels('litellm'), + defaultModel: getProviderDefaultModel('litellm'), + + async initialize() { + if (typeof window !== 'undefined') { + logger.info('Skipping LiteLLM initialization on client side to avoid CORS issues') + return + } + + const baseUrl = (env.LITELLM_BASE_URL || '').replace(/\/$/, '') + if (!baseUrl) { + logger.info('LITELLM_BASE_URL not configured, skipping initialization') + return + } + + try { + 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 }) + if (!response.ok) { + await response.text().catch(() => {}) + useProvidersStore.getState().setProviderModels('litellm', []) + logger.warn('LiteLLM service is not available. The provider will be disabled.') + return + } + + const { vllmUpstreamResponseSchema } = await import('@/lib/api/contracts/providers') + const data = vllmUpstreamResponseSchema.parse(await response.json()) + const models = data.data.map((model) => `litellm/${model.id}`) + + this.models = models + useProvidersStore.getState().setProviderModels('litellm', models) + + logger.info(`Discovered ${models.length} LiteLLM model(s):`, { models }) + } catch (error) { + logger.warn('LiteLLM model instantiation failed. The provider will be disabled.', { + error: getErrorMessage(error, 'Unknown error'), + }) + } + }, + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + logger.info('Preparing LiteLLM request', { + model: request.model, + hasSystemPrompt: !!request.systemPrompt, + hasMessages: !!request.messages?.length, + hasTools: !!request.tools?.length, + toolCount: request.tools?.length || 0, + hasResponseFormat: !!request.responseFormat, + stream: !!request.stream, + }) + + const baseUrl = (env.LITELLM_BASE_URL || '').replace(/\/$/, '') + if (!baseUrl) { + throw new Error('LITELLM_BASE_URL is required for LiteLLM provider') + } + + const apiKey = request.apiKey || env.LITELLM_API_KEY || 'empty' + const litellm = new OpenAI({ + apiKey, + baseURL: `${baseUrl}/v1`, + }) + + const allMessages: Message[] = [] + + if (request.systemPrompt) { + allMessages.push({ + role: 'system', + content: request.systemPrompt, + }) + } + + if (request.context) { + allMessages.push({ + role: 'user', + content: request.context, + }) + } + + if (request.messages) { + allMessages.push(...request.messages) + } + const formattedMessages = formatMessagesForProvider(allMessages, 'litellm') as Message[] + + const tools = request.tools?.length + ? request.tools.map((tool) => ({ + type: 'function', + function: { + name: tool.id, + description: tool.description, + parameters: tool.parameters, + }, + })) + : undefined + + const payload: any = { + model: request.model.replace(/^litellm\//, ''), + messages: formattedMessages, + } + + if (request.temperature !== undefined) payload.temperature = request.temperature + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + + if (request.responseFormat) { + payload.response_format = { + type: 'json_schema', + json_schema: { + name: request.responseFormat.name || 'response_schema', + schema: request.responseFormat.schema || request.responseFormat, + strict: request.responseFormat.strict !== false, + }, + } + + logger.info('Added JSON schema response format to LiteLLM request') + } + + let preparedTools: ReturnType | null = null + let hasActiveTools = false + + if (tools?.length) { + preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'litellm') + const { tools: filteredTools, toolChoice } = preparedTools + + if (filteredTools?.length && toolChoice) { + payload.tools = filteredTools + payload.tool_choice = toolChoice + hasActiveTools = true + + logger.info('LiteLLM request configuration:', { + toolCount: filteredTools.length, + toolChoice: + typeof toolChoice === 'string' + ? toolChoice + : toolChoice.type === 'function' + ? `force:${toolChoice.function.name}` + : 'unknown', + model: payload.model, + }) + } + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { + logger.info('Using streaming response for LiteLLM request') + + const streamingParams: ChatCompletionCreateParamsStreaming = { + ...payload, + stream: true, + stream_options: { include_usage: true }, + } + const streamResponse = await litellm.chat.completions.create( + streamingParams, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const streamingResult = { + stream: createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { + let cleanContent = content + if (cleanContent && request.responseFormat) { + cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() + } + + streamingResult.execution.output.content = cleanContent + streamingResult.execution.output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + streamingResult.execution.output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + + const streamEndTime = Date.now() + const streamEndTimeISO = new Date(streamEndTime).toISOString() + + if (streamingResult.execution.output.providerTiming) { + streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO + streamingResult.execution.output.providerTiming.duration = + streamEndTime - providerStartTime + + if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { + streamingResult.execution.output.providerTiming.timeSegments[0].endTime = + streamEndTime + streamingResult.execution.output.providerTiming.timeSegments[0].duration = + streamEndTime - providerStartTime + } + } + }), + execution: { + success: true, + output: { + content: '', + model: request.model, + tokens: { input: 0, output: 0, total: 0 }, + toolCalls: undefined, + providerTiming: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + timeSegments: [ + { + type: 'model', + name: request.model, + startTime: providerStartTime, + endTime: Date.now(), + duration: Date.now() - providerStartTime, + }, + ], + }, + cost: { input: 0, output: 0, total: 0 }, + }, + logs: [], + metadata: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + }, + }, + } as StreamingExecution + + return streamingResult as StreamingExecution + } + + const initialCallTime = Date.now() + + const originalToolChoice = payload.tool_choice + + const forcedTools = preparedTools?.forcedTools || [] + let usedForcedTools: string[] = [] + + const checkForForcedToolUsage = ( + response: any, + toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } + ) => { + if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) { + const toolCallsResponse = response.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + toolChoice, + logger, + 'litellm', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + } + + let currentResponse = await litellm.chat.completions.create( + payload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const firstResponseTime = Date.now() - initialCallTime + + let content = currentResponse.choices[0]?.message?.content || '' + + if (content && request.responseFormat) { + content = content.replace(/```json\n?|\n?```/g, '').trim() + } + + const tokens = { + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, + total: currentResponse.usage?.total_tokens || 0, + } + const toolCalls = [] + const toolResults: Record[] = [] + const currentMessages = [...formattedMessages] + let iterationCount = 0 + + let modelTime = firstResponseTime + let toolsTime = 0 + + let hasUsedForcedTool = false + + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: request.model, + startTime: initialCallTime, + endTime: initialCallTime + firstResponseTime, + duration: firstResponseTime, + }, + ] + + checkForForcedToolUsage(currentResponse, originalToolChoice) + + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + if (request.responseFormat) { + content = content.replace(/```json\n?|\n?```/g, '').trim() + } + } + + const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'litellm' } + ) + + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { + break + } + + logger.info( + `Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_TOOL_ITERATIONS})` + ) + + const toolsStartTime = Date.now() + + const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { + const toolCallStartTime = Date.now() + const toolName = toolCall.function.name + + try { + const toolArgs = JSON.parse(toolCall.function.arguments) + const tool = request.tools?.find((t) => t.id === toolName) + + if (!tool) return null + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + + return { + toolCall, + toolName, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } catch (error) { + const toolCallEndTime = Date.now() + logger.error('Error processing tool call:', { error, toolName }) + + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + }) + + const executionResults = await Promise.allSettled(toolExecutionPromises) + + currentMessages.push({ + role: 'assistant', + content: null, + tool_calls: toolCallsInResponse.map((tc) => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })), + }) + + for (const settledResult of executionResults) { + if (settledResult.status === 'rejected' || !settledResult.value) continue + + const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = + settledResult.value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime: startTime, + endTime: endTime, + duration: duration, + toolCallId: toolCall.id, + }) + + let resultContent: any + if (result.success && result.output) { + toolResults.push(result.output) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration: duration, + result: resultContent, + success: result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(resultContent), + }) + } + + const thisToolsTime = Date.now() - toolsStartTime + toolsTime += thisToolsTime + + const nextPayload = { + ...payload, + messages: currentMessages, + } + + if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + + if (remainingTools.length > 0) { + nextPayload.tool_choice = { + type: 'function', + function: { name: remainingTools[0] }, + } + logger.info(`Forcing next tool: ${remainingTools[0]}`) + } else { + nextPayload.tool_choice = 'auto' + logger.info('All forced tools have been used, switching to auto tool_choice') + } + } + + const nextModelStartTime = Date.now() + + currentResponse = await litellm.chat.completions.create( + nextPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + checkForForcedToolUsage(currentResponse, nextPayload.tool_choice) + + const nextModelEndTime = Date.now() + const thisModelTime = nextModelEndTime - nextModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: nextModelStartTime, + endTime: nextModelEndTime, + duration: thisModelTime, + }) + + modelTime += thisModelTime + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + if (request.responseFormat) { + content = content.replace(/```json\n?|\n?```/g, '').trim() + } + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + iterationCount++ + } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'litellm' } + ) + } + + if (request.stream) { + logger.info('Using streaming for final response after tool processing') + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + + const streamingParams: ChatCompletionCreateParamsStreaming = { + ...payload, + messages: currentMessages, + tool_choice: 'auto', + stream: true, + stream_options: { include_usage: true }, + } + const streamResponse = await litellm.chat.completions.create( + streamingParams, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const streamingResult = { + stream: createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { + let cleanContent = content + if (cleanContent && request.responseFormat) { + cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() + } + + streamingResult.execution.output.content = cleanContent + streamingResult.execution.output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + streamingResult.execution.output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + execution: { + success: true, + output: { + content: '', + model: request.model, + tokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + providerTiming: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + modelTime: modelTime, + toolsTime: toolsTime, + firstResponseTime: firstResponseTime, + iterations: iterationCount + 1, + timeSegments: timeSegments, + }, + cost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, + }, + }, + logs: [], + metadata: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + }, + }, + } as StreamingExecution + + return streamingResult as StreamingExecution + } + + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + return { + content, + model: request.model, + tokens, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + timing: { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + modelTime: modelTime, + toolsTime: toolsTime, + firstResponseTime: firstResponseTime, + iterations: iterationCount + 1, + timeSegments: timeSegments, + }, + } + } catch (error) { + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + let errorMessage = toError(error).message + let errorType: string | undefined + let errorCode: number | undefined + + if (error && typeof error === 'object' && 'error' in error) { + const litellmError = error.error as any + if (litellmError && typeof litellmError === 'object') { + errorMessage = litellmError.message || errorMessage + errorType = litellmError.type + errorCode = litellmError.code + } + } + + logger.error('Error in LiteLLM request:', { + error: errorMessage, + errorType, + errorCode, + duration: totalDuration, + }) + + throw new ProviderError(errorMessage, { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + }) + } + }, +} diff --git a/apps/sim/providers/litellm/utils.ts b/apps/sim/providers/litellm/utils.ts new file mode 100644 index 00000000000..f779f95c703 --- /dev/null +++ b/apps/sim/providers/litellm/utils.ts @@ -0,0 +1,14 @@ +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { createOpenAICompatibleStream } from '@/providers/utils' + +/** + * Creates a ReadableStream from a LiteLLM streaming response. + * Uses the shared OpenAI-compatible streaming utility. + */ +export function createReadableStreamFromLiteLLMStream( + litellmStream: AsyncIterable, + onComplete?: (content: string, usage: CompletionUsage) => void +): ReadableStream { + return createOpenAICompatibleStream(litellmStream, 'LiteLLM', onComplete) +} diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 82497c87d04..375506cde25 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -17,6 +17,7 @@ import { FireworksIcon, GeminiIcon, GroqIcon, + LitellmIcon, MistralIcon, OllamaIcon, OpenAIIcon, @@ -125,6 +126,20 @@ export const PROVIDER_DEFINITIONS: Record = { }, models: [], }, + litellm: { + id: 'litellm', + name: 'LiteLLM', + icon: LitellmIcon, + color: '#040229', + description: 'LiteLLM proxy with an OpenAI-compatible API', + defaultModel: '', + modelPatterns: [/^litellm\//], + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + }, + models: [], + }, openai: { id: 'openai', name: 'OpenAI', @@ -2803,7 +2818,13 @@ export function getProviderModels(providerId: string): string[] { return PROVIDER_DEFINITIONS[providerId]?.models.map((m) => m.id) || [] } -export const DYNAMIC_MODEL_PROVIDERS = ['ollama', 'vllm', 'openrouter', 'fireworks'] as const +export const DYNAMIC_MODEL_PROVIDERS = [ + 'ollama', + 'vllm', + 'litellm', + 'openrouter', + 'fireworks', +] as const function getAllStaticModelIds(): string[] { const ids: string[] = [] @@ -2857,7 +2878,7 @@ export function suggestModelIdsForUnknownModel(_modelId: string, limit = 5): str export function getBaseModelProviders(): Record { return Object.entries(PROVIDER_DEFINITIONS) - .filter(([providerId]) => !['ollama', 'vllm', 'openrouter'].includes(providerId)) + .filter(([providerId]) => !['ollama', 'vllm', 'litellm', 'openrouter'].includes(providerId)) .reduce( (map, [providerId, provider]) => { provider.models.forEach((model) => { @@ -3034,6 +3055,18 @@ export function updateVLLMModels(models: string[]): void { })) } +export function updateLiteLLMModels(models: string[]): void { + PROVIDER_DEFINITIONS.litellm.models = models.map((modelId) => ({ + id: modelId, + pricing: { + input: 0, + output: 0, + updatedAt: new Date().toISOString().split('T')[0], + }, + capabilities: {}, + })) +} + export function updateFireworksModels(models: string[]): void { PROVIDER_DEFINITIONS.fireworks.models = models.map((modelId) => ({ id: modelId, diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index 8b1256c2de7..5aa48d3db3a 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -9,6 +9,7 @@ import { deepseekProvider } from '@/providers/deepseek' import { fireworksProvider } from '@/providers/fireworks' import { googleProvider } from '@/providers/google' import { groqProvider } from '@/providers/groq' +import { litellmProvider } from '@/providers/litellm' import { mistralProvider } from '@/providers/mistral' import { ollamaProvider } from '@/providers/ollama' import { openaiProvider } from '@/providers/openai' @@ -31,6 +32,7 @@ const providerRegistry: Record = { cerebras: cerebrasProvider, groq: groqProvider, vllm: vllmProvider, + litellm: litellmProvider, mistral: mistralProvider, 'azure-openai': azureOpenAIProvider, openrouter: openRouterProvider, diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 007b9b3ead5..dc2f25927d6 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -16,6 +16,7 @@ export type ProviderId = | 'openrouter' | 'fireworks' | 'vllm' + | 'litellm' | 'bedrock' export interface ModelPricing { diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 0b46003ca4a..03e50c78f24 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -168,6 +168,19 @@ describe('getApiKey', () => { expect(key2).toBe('user-key') } ) + + it.concurrent( + 'should return empty or user-provided key for litellm provider without requiring API key', + () => { + isHostedSpy.mockReturnValue(false) + + const key = getApiKey('litellm', 'litellm/anthropic/claude-sonnet-4-6') + expect(key).toBe('empty') + + const key2 = getApiKey('litellm', 'litellm/openai/gpt-4', 'user-key') + expect(key2).toBe('user-key') + } + ) }) describe('Model Capabilities', () => { diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index a646214b148..205fb307873 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -132,6 +132,7 @@ function buildProviderMetadata(providerId: ProviderId): ProviderMetadata { export const providers: Record = { ollama: buildProviderMetadata('ollama'), vllm: buildProviderMetadata('vllm'), + litellm: buildProviderMetadata('litellm'), openai: { ...buildProviderMetadata('openai'), computerUseModels: ['computer-use-preview'], @@ -167,6 +168,12 @@ export function updateVLLMProviderModels(models: string[]): void { providers.vllm.models = getProviderModelsFromDefinitions('vllm') } +export function updateLiteLLMProviderModels(models: string[]): void { + const { updateLiteLLMModels } = require('@/providers/models') + updateLiteLLMModels(models) + providers.litellm.models = getProviderModelsFromDefinitions('litellm') +} + export async function updateOpenRouterProviderModels(models: string[]): Promise { const { updateOpenRouterModels } = await import('@/providers/models') updateOpenRouterModels(models) @@ -185,6 +192,7 @@ export function getBaseModelProviders(): Record { ([providerId]) => providerId !== 'ollama' && providerId !== 'vllm' && + providerId !== 'litellm' && providerId !== 'openrouter' && providerId !== 'fireworks' ) @@ -744,6 +752,12 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str return userProvidedKey || 'empty' } + const isLitellmModel = + provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model) + if (isLitellmModel) { + return userProvidedKey || 'empty' + } + // Bedrock uses its own credentials (bedrockAccessKeyId/bedrockSecretKey), not apiKey const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/') if (isBedrockModel) { diff --git a/apps/sim/stores/providers/store.ts b/apps/sim/stores/providers/store.ts index 4567812e0f8..00896c0ba7c 100644 --- a/apps/sim/stores/providers/store.ts +++ b/apps/sim/stores/providers/store.ts @@ -9,6 +9,7 @@ export const useProvidersStore = create((set, get) => ({ base: { models: [], isLoading: false }, ollama: { models: [], isLoading: false }, vllm: { models: [], isLoading: false }, + litellm: { models: [], isLoading: false }, openrouter: { models: [], isLoading: false }, fireworks: { models: [], isLoading: false }, }, diff --git a/apps/sim/stores/providers/types.ts b/apps/sim/stores/providers/types.ts index e76870c04cf..7022529f202 100644 --- a/apps/sim/stores/providers/types.ts +++ b/apps/sim/stores/providers/types.ts @@ -1,4 +1,4 @@ -export type ProviderName = 'ollama' | 'vllm' | 'openrouter' | 'fireworks' | 'base' +export type ProviderName = 'ollama' | 'vllm' | 'litellm' | 'openrouter' | 'fireworks' | 'base' export interface OpenRouterModelInfo { id: string diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0fbe9d4077a..b3c105ab531 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 755, - zodRoutes: 755, + totalRoutes: 756, + zodRoutes: 756, nonZodRoutes: 0, } as const From 3b18d3ba0e890605a06e64739d2d1ec0c51a981c Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 26 May 2026 11:00:29 -0700 Subject: [PATCH 03/10] fix(api): classify access-denied and sandbox user-code errors with correct HTTP status (#4740) * fix(api): classify access-denied and sandbox user-code errors with correct HTTP status * fix(api): gate typed-error message exposure behind publicMessage opt-in * refactor(api): match NestJS/Spring convention for typed-error message exposure --- apps/sim/app/api/copilot/chat/queries.ts | 9 ++++- apps/sim/app/api/copilot/chats/route.ts | 9 ++++- apps/sim/app/api/function/execute/route.ts | 6 +-- .../mothership/chats/[chatId]/fork/route.ts | 9 ++++- apps/sim/app/api/mothership/chats/route.ts | 12 +++++- apps/sim/app/api/mothership/execute/route.ts | 5 +++ apps/sim/app/api/tools/file/manage/route.ts | 11 +++++- apps/sim/lib/copilot/chat/post.ts | 9 ++++- apps/sim/lib/copilot/request/http.ts | 4 ++ apps/sim/lib/core/utils/with-route-handler.ts | 37 ++++++++++++++++++- apps/sim/lib/workspaces/permissions/utils.ts | 23 +++++++++++- 11 files changed, 122 insertions(+), 12 deletions(-) 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/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/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index a7ba4573879..f13f4a85046 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -44,7 +44,10 @@ import { taskPubSub } from '@/lib/copilot/tasks' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + getUserEntityPermissions, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' import type { ChatContext } from '@/stores/panel' export const maxDuration = 3600 @@ -1039,6 +1042,10 @@ export async function handleUnifiedChatPost(req: NextRequest) { return validationErrorResponse(error, 'Invalid request data') } + if (isWorkspaceAccessDeniedError(error)) { + return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 }) + } + logger.error(`[${requestId}] Error handling unified chat request`, { error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, diff --git a/apps/sim/lib/copilot/request/http.ts b/apps/sim/lib/copilot/request/http.ts index 19c33fc7545..56614265ff7 100644 --- a/apps/sim/lib/copilot/request/http.ts +++ b/apps/sim/lib/copilot/request/http.ts @@ -35,6 +35,10 @@ export function createBadRequestResponse(message: string): NextResponse { return NextResponse.json({ error: message }, { status: 400 }) } +export function createForbiddenResponse(message: string): NextResponse { + return NextResponse.json({ error: message }, { status: 403 }) +} + export function createNotFoundResponse(message: string): NextResponse { return NextResponse.json({ error: message }, { status: 404 }) } diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 5b3212f23e5..44857eba011 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -11,6 +11,26 @@ type RouteHandler = ( context: T ) => Promise | NextResponse | Response +/** + * Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors + * (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the correct + * HTTP status when they bubble up unhandled instead of defaulting to 500. + * + * When a typed status is returned, the error's `message` is sent to the client + * verbatim — matching the NestJS `HttpException` / Spring `ResponseStatusException` + * convention. The safety contract is convention-based: only attach `statusCode` + * to errors whose `message` is safe to expose to clients (no stack traces, + * secrets, file paths, ORM internals). Untyped errors fall back to a generic + * 500 response with no message exposure. + */ +function readTypedErrorStatus(error: unknown): number | undefined { + if (!(error instanceof Error)) return undefined + const status = (error as { statusCode?: unknown }).statusCode + if (typeof status !== 'number') return undefined + if (status < 400 || status >= 600) return undefined + return status +} + /** * Wraps a Next.js API route handler with centralized error reporting. * @@ -35,8 +55,21 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { } catch (error) { const duration = Date.now() - startTime const message = getErrorMessage(error, 'Unknown error') - logger.error('Unhandled route error', { duration, error: message }) - response = NextResponse.json({ error: 'Internal server error', requestId }, { status: 500 }) + const typedStatus = readTypedErrorStatus(error) + if (typedStatus !== undefined) { + if (typedStatus >= 500) { + logger.error('Unhandled route error', { duration, status: typedStatus, error: message }) + } else { + logger.warn('Typed route error', { duration, status: typedStatus, error: message }) + } + response = NextResponse.json({ error: message, requestId }, { status: typedStatus }) + } else { + logger.error('Unhandled route error', { duration, error: message }) + response = NextResponse.json( + { error: 'Internal server error', requestId }, + { status: 500 } + ) + } response?.headers?.set('x-request-id', requestId) return response } diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index bef0da42e42..15a5f2b7e7d 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -147,13 +147,34 @@ export async function checkWorkspaceAccess( return { exists: true, hasAccess: true, canWrite, workspace: ws } } +/** + * Thrown when a user attempts to access a workspace they don't have access to, + * or that doesn't exist / has been archived. Carries `statusCode = 403` so the + * centralized route wrapper maps it to HTTP 403 instead of defaulting to 500. + * The `message` is intentionally client-safe and is exposed to API responses. + */ +export class WorkspaceAccessDeniedError extends Error { + readonly statusCode = 403 + readonly workspaceId: string + + constructor(workspaceId: string) { + super(`Workspace access denied: ${workspaceId}`) + this.name = 'WorkspaceAccessDeniedError' + this.workspaceId = workspaceId + } +} + +export function isWorkspaceAccessDeniedError(error: unknown): error is WorkspaceAccessDeniedError { + return error instanceof WorkspaceAccessDeniedError +} + export async function assertActiveWorkspaceAccess( workspaceId: string, userId: string ): Promise { const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.exists || !access.hasAccess) { - throw new Error(`Active workspace access denied: ${workspaceId}`) + throw new WorkspaceAccessDeniedError(workspaceId) } return access } From bc99c457b9d46b59ae87f94dcd61775ea5c6d954 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 26 May 2026 12:32:49 -0700 Subject: [PATCH 04/10] fix(files): zoom file viewer content, not the browser page (#4741) * fix(files): zoom file viewer content, not the browser page * fix(files): use effect lifecycle for SVG blob URL to survive strict mode --- .../components/file-viewer/preview-panel.tsx | 18 ++++-- .../file-viewer/preview-wheel-zoom.ts | 28 ++++++++- .../file-viewer/zoomable-preview.tsx | 62 +++++++++++-------- 3 files changed, 72 insertions(+), 36 deletions(-) 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..654fae2feb7 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,15 +1071,21 @@ 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 ( -