From d28a752ff1863ed94ba4f305d1937f29f555aa9a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:00:44 -0700 Subject: [PATCH 01/16] feat(providers): support large agent-block attachments via Files APIs and remote URLs Agent-block file uploads were inlined as base64 with a hard 10MB cap. Files above the threshold now use each provider's native large-file path: - OpenAI / Gemini: upload to the provider Files API, reference by file_id/uri - Anthropic: GA url content-block source (no Files API beta, no upload) - OpenRouter/Groq/Together/Baseten/xAI/vLLM: remote signed URL in image_url/file - Limits live per-provider in models.ts; the agent block + /models page reflect them Files <=10MB keep the identical base64 path (zero regression). Server-only file handles are stripped from untrusted input to prevent SSRF. --- .../models/(shell)/[provider]/page.tsx | 14 +- apps/sim/app/(landing)/models/utils.ts | 15 ++ .../components/file-upload/file-upload.tsx | 17 +- .../executor/handlers/agent/agent-handler.ts | 9 +- apps/sim/executor/types.ts | 6 + apps/sim/lib/uploads/utils/file-utils.test.ts | 35 +++- apps/sim/lib/uploads/utils/file-utils.ts | 9 +- apps/sim/providers/attachments.test.ts | 101 ++++++++++ apps/sim/providers/attachments.ts | 163 +++++++++++---- apps/sim/providers/file-attachments.server.ts | 187 ++++++++++++++++++ apps/sim/providers/index.ts | 3 + apps/sim/providers/models.ts | 37 ++++ 12 files changed, 546 insertions(+), 50 deletions(-) create mode 100644 apps/sim/providers/file-attachments.server.ts diff --git a/apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx b/apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx index 1147de38990..76139ce8421 100644 --- a/apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx +++ b/apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx @@ -13,6 +13,7 @@ import { import { ModelTimelineChart } from '@/app/(landing)/models/components/model-timeline-chart' import { buildProviderFaqs, + formatFileSize, formatPrice, formatTokenCount, getProviderBySlug, @@ -204,9 +205,16 @@ export default async function ProviderModelsPage({ {provider.name} models - - {provider.modelCount} models - +
+ + {provider.modelCount} models + + {provider.maxFileAttachmentBytes ? ( + + {formatFileSize(provider.maxFileAttachmentBytes)} file uploads + + ) : null} +
diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts index 8942dfa948d..4a8cbefc347 100644 --- a/apps/sim/app/(landing)/models/utils.ts +++ b/apps/sim/app/(landing)/models/utils.ts @@ -127,6 +127,8 @@ export interface CatalogProvider { color?: string isReseller: boolean contextInformationAvailable: boolean + /** Max agent-block file attachment size in bytes when the provider exceeds the default. */ + maxFileAttachmentBytes: number | null providerCapabilityTags: string[] modelCount: number models: CatalogModel[] @@ -150,6 +152,18 @@ export function formatTokenCount(value?: number | null): string { return value.toLocaleString('en-US') } +export function formatFileSize(bytes?: number | null): string { + if (bytes == null) { + return 'Unknown' + } + + const gb = bytes / (1024 * 1024 * 1024) + if (gb >= 1) { + return `${trimTrailingZeros(gb.toFixed(1))}GB` + } + return `${Math.round(bytes / (1024 * 1024))}MB` +} + export function formatPrice(price?: number | null): string { if (price === undefined || price === null) { return 'N/A' @@ -507,6 +521,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => { color: provider.color, isReseller: provider.isReseller ?? false, contextInformationAvailable: provider.contextInformationAvailable !== false, + maxFileAttachmentBytes: provider.fileAttachment?.maxBytes ?? null, providerCapabilityTags, modelCount: models.length, models, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 6e441b3a458..0b4cbea70ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -24,6 +24,8 @@ import { useWorkspaceFiles, workspaceFilesKeys, } from '@/hooks/queries/workspace-files' +import { getProviderAttachmentMaxBytes } from '@/providers/attachments' +import { getProviderFromModel } from '@/providers/models' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -167,6 +169,7 @@ export function FileUpload({ }: FileUploadProps) { const activeSearchTarget = useActiveSearchTarget() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [modelValue] = useSubBlockValue(blockId, 'model') const [uploadingFiles, setUploadingFiles] = useState([]) const [uploadProgress, setUploadProgress] = useState(0) const [uploadError, setUploadError] = useState(null) @@ -191,6 +194,17 @@ export function FileUpload({ const value = isPreview ? previewValue : storeValue + const maxSizeInBytes = useMemo(() => { + const fallback = maxSize * 1024 * 1024 + if (typeof modelValue !== 'string' || !modelValue) return fallback + try { + return Math.max(fallback, getProviderAttachmentMaxBytes(getProviderFromModel(modelValue))) + } catch { + return fallback + } + }, [modelValue, maxSize]) + const maxSizeLabel = `${Math.round(maxSizeInBytes / (1024 * 1024))}MB` + /** * Checks if a file's MIME type matches the accepted types * Supports exact matches, wildcard patterns (e.g., 'image/*'), and '*' for all types @@ -281,7 +295,6 @@ export function FileUpload({ const existingFiles = Array.isArray(value) ? value : value ? [value] : [] const existingTotalSize = existingFiles.reduce((sum, file) => sum + file.size, 0) - const maxSizeInBytes = maxSize * 1024 * 1024 const validFiles: File[] = [] let totalNewSize = 0 let sizeExceededFile: string | null = null @@ -289,7 +302,7 @@ export function FileUpload({ for (let i = 0; i < files.length; i++) { const file = files[i] if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) { - const errorMessage = `Adding ${file.name} would exceed the maximum size limit of ${maxSize}MB` + const errorMessage = `Adding ${file.name} would exceed the maximum size limit of ${maxSizeLabel}` logger.error(errorMessage, activeWorkflowId) if (!sizeExceededFile) { sizeExceededFile = errorMessage diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index b9c3a3eaad8..ab8cd8abe22 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -39,7 +39,8 @@ import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { stringifyJSON } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' -import { getProviderAttachmentMaxBytes, supportsFileAttachments } from '@/providers/attachments' +import { INLINE_ATTACHMENT_THRESHOLD_BYTES, supportsFileAttachments } from '@/providers/attachments' +import { attachLargeFileRemoteUrls } from '@/providers/file-attachments.server' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' import { filterSchemaForLLM, type ToolSchema } from '@/tools/params' @@ -760,10 +761,12 @@ export class AgentBlockHandler implements BlockHandler { allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope, userId: ctx.userId, logger, - maxBytes: getProviderAttachmentMaxBytes(providerId), + maxBytes: INLINE_ATTACHMENT_THRESHOLD_BYTES, }) - const missingFile = hydratedFiles.find((file) => !file.base64) + await attachLargeFileRemoteUrls(hydratedFiles, providerId, { requestId, userId: ctx.userId }) + + const missingFile = hydratedFiles.find((file) => !file.base64 && !file.remoteUrl) if (missingFile) { throw new Error( `File "${missingFile.name}" could not be read for provider "${providerId}". The file may exceed the attachment size limit or may no longer be accessible.` diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 4995ad7ea29..3e9ef6a6e40 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -20,6 +20,12 @@ export interface UserFile { key: string context?: string base64?: string + /** Provider Files API handle (OpenAI/Anthropic `file_...` id) set when a large file is uploaded instead of inlined as base64. */ + providerFileId?: string + /** Provider File API uri (Gemini `fileUri`) set when a large file is uploaded instead of inlined as base64. */ + providerFileUri?: string + /** Short-lived signed HTTPS URL passed to providers that fetch attachments by remote URL instead of inlining base64. */ + remoteUrl?: string } export interface ParallelPauseScope { diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index 63793e0e144..9a7e82185f8 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -1,8 +1,16 @@ /** * @vitest-environment node */ +import { createLogger } from '@sim/logger' import { describe, expect, it } from 'vitest' -import { isAbortError, isInternalFileUrl, isNetworkError } from '@/lib/uploads/utils/file-utils' +import { + isAbortError, + isInternalFileUrl, + isNetworkError, + processSingleFileToUserFile, +} from '@/lib/uploads/utils/file-utils' + +const logger = createLogger('FileUtilsTest') describe('isInternalFileUrl', () => { it('classifies relative serve paths as internal', () => { @@ -72,3 +80,28 @@ describe('isNetworkError', () => { expect(isNetworkError(null)).toBe(false) }) }) + +describe('processSingleFileToUserFile', () => { + it('strips server-only provider file handles from untrusted input', () => { + const result = processSingleFileToUserFile( + { + id: 'file-1', + name: 'doc.pdf', + url: '/api/files/serve/workspace%2Fws-1%2Fdoc.pdf?context=workspace', + size: 1024, + type: 'application/pdf', + key: 'workspace/ws-1/doc.pdf', + providerFileId: 'file-injected', + providerFileUri: 'https://injected/uri', + remoteUrl: 'http://169.254.169.254/latest/meta-data', + } as never, + 'req-1', + logger + ) + + expect(result.providerFileId).toBeUndefined() + expect(result.providerFileUri).toBeUndefined() + expect(result.remoteUrl).toBeUndefined() + expect(result.key).toBe('workspace/ws-1/doc.pdf') + }) +}) diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index e99d83c0564..ac61f46d774 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -1,4 +1,5 @@ import type { Logger } from '@sim/logger' +import { omit } from '@sim/utils/object' import type { StorageContext } from '@/lib/uploads' import { ACCEPTED_FILE_TYPES, SUPPORTED_DOCUMENT_EXTENSIONS } from '@/lib/uploads/utils/validation' import { isUuid } from '@/executor/constants' @@ -696,13 +697,19 @@ function resolveInternalFileUrl(file: RawFileInput): string { return '' } +/** + * Provider large-file handles are populated by the server pipeline and must never be + * accepted from untrusted file input (they drive server-side fetch/upload). + */ +const PROVIDER_FILE_HANDLE_FIELDS = ['providerFileId', 'providerFileUri', 'remoteUrl'] as const + /** * Core conversion logic from RawFileInput to UserFile */ function convertToUserFile(file: RawFileInput, requestId: string, logger: Logger): UserFile | null { if (isCompleteUserFile(file)) { return { - ...file, + ...omit(file, PROVIDER_FILE_HANDLE_FIELDS), url: resolveInternalFileUrl(file) || file.url, } } diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index 1b3541b10ba..bca5326f090 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -7,11 +7,16 @@ import { buildAnthropicMessageContent, buildBedrockMessageContent, buildGeminiMessageParts, + buildOpenAICompatibleChatContent, buildOpenAIMessageContent, buildOpenRouterMessageContent, formatMessagesForProvider, + getProviderAttachmentMaxBytes, + getProviderFileStrategy, + INLINE_ATTACHMENT_THRESHOLD_BYTES, inferAttachmentMimeType, prepareProviderAttachments, + shouldUseLargeFilePath, } from '@/providers/attachments' const imageFile: UserFile = { @@ -270,3 +275,99 @@ describe('provider attachments', () => { ).toThrow('not supported') }) }) + +describe('provider large-file capability', () => { + it('reports per-provider strategy and ceiling, defaulting others to inline', () => { + expect(getProviderFileStrategy('openai')).toBe('files-api') + expect(getProviderFileStrategy('google')).toBe('files-api') + expect(getProviderFileStrategy('anthropic')).toBe('remote-url') + expect(getProviderFileStrategy('groq')).toBe('remote-url') + expect(getProviderFileStrategy('bedrock')).toBe('inline') + expect(getProviderFileStrategy('azure-openai')).toBe('inline') + expect(getProviderFileStrategy('vertex')).toBe('inline') + + expect(getProviderAttachmentMaxBytes('openai')).toBeGreaterThan( + INLINE_ATTACHMENT_THRESHOLD_BYTES + ) + expect(getProviderAttachmentMaxBytes('bedrock')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES) + expect(getProviderAttachmentMaxBytes('azure-openai')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES) + }) + + it('routes only oversized files on capable providers to the large-file path', () => { + const small = { ...imageFile, size: 1024 } + const large = { ...imageFile, size: INLINE_ATTACHMENT_THRESHOLD_BYTES + 1 } + expect(shouldUseLargeFilePath(small, 'openai')).toBe(false) + expect(shouldUseLargeFilePath(large, 'openai')).toBe(true) + expect(shouldUseLargeFilePath(large, 'bedrock')).toBe(false) + }) + + it('references uploaded OpenAI files by file_id instead of inlining base64', () => { + const content = buildOpenAIMessageContent( + 'Analyze', + [ + { ...imageFile, base64: undefined, providerFileId: 'file-img' }, + { ...pdfFile, base64: undefined, providerFileId: 'file-doc' }, + ], + 'openai' + ) + expect(content).toEqual([ + { type: 'input_text', text: 'Analyze' }, + { type: 'input_image', file_id: 'file-img', detail: 'auto' }, + { type: 'input_file', file_id: 'file-doc' }, + ]) + }) + + it('references large Anthropic files via url content-block sources', () => { + const content = buildAnthropicMessageContent( + 'Analyze', + [ + { ...imageFile, base64: undefined, remoteUrl: 'https://signed/img.png' }, + { ...pdfFile, base64: undefined, remoteUrl: 'https://signed/doc.pdf' }, + ], + 'anthropic' + ) + expect(content).toEqual([ + { type: 'text', text: 'Analyze' }, + { type: 'image', source: { type: 'url', url: 'https://signed/img.png' } }, + { + type: 'document', + source: { type: 'url', url: 'https://signed/doc.pdf' }, + title: 'example.pdf', + }, + ]) + }) + + it('references uploaded Gemini files via fileData uri', () => { + const parts = buildGeminiMessageParts( + 'Analyze', + [{ ...imageFile, base64: undefined, providerFileUri: 'https://files/abc' }], + 'google' + ) + expect(parts).toEqual([ + { text: 'Analyze' }, + { fileData: { fileUri: 'https://files/abc', mimeType: 'image/png' } }, + ]) + }) + + it('passes a remote url to OpenAI-compatible providers instead of a data url', () => { + const content = buildOpenAICompatibleChatContent( + 'Analyze', + [{ ...imageFile, base64: undefined, remoteUrl: 'https://signed/img.png' }], + 'groq' + ) + expect(content).toEqual([ + { type: 'text', text: 'Analyze' }, + { type: 'image_url', image_url: { url: 'https://signed/img.png' } }, + ]) + }) + + it('rejects files above the provider ceiling', () => { + const huge = { + ...imageFile, + size: getProviderAttachmentMaxBytes('openai') + 1, + base64: undefined, + providerFileId: 'file-img', + } + expect(() => buildOpenAIMessageContent('Analyze', [huge], 'openai')).toThrow('exceeds the') + }) +}) diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index d9edad96fd5..69e7d0e196a 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -11,6 +11,11 @@ import { MODEL_SUPPORTED_IMAGE_MIME_TYPES, } from '@/lib/uploads/utils/file-utils' import type { UserFile } from '@/executor/types' +import { + getProviderFileAttachment, + INLINE_ATTACHMENT_MAX_BYTES, + type ProviderFileAttachmentStrategy, +} from '@/providers/models' import type { ProviderId } from '@/providers/types' export type AttachmentProvider = @@ -36,11 +41,19 @@ export interface PreparedProviderAttachment { filename: string mimeType: string providerMimeType: string - base64: string - dataUrl: string + /** Base64 payload — present only for inlined files (≤ inline threshold). Absent for large uploaded files. */ + base64?: string + /** `data:` URL — present only for inlined files. Absent for large uploaded files. */ + dataUrl?: string text?: string extension: string contentType: 'image' | 'document' | 'audio' | 'video' + /** Provider Files API id (OpenAI/Anthropic) when the file was uploaded instead of inlined. */ + providerFileId?: string + /** Provider File API uri (Gemini) when the file was uploaded instead of inlined. */ + providerFileUri?: string + /** Short-lived signed HTTPS URL for providers that fetch attachments by remote URL. */ + remoteUrl?: string } type ProviderMessageInput = { @@ -56,7 +69,29 @@ type ProviderFormattedMessage = { [key: string]: unknown } -export const AGENT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 +/** + * Files at or below this size are inlined as base64, exactly as before. Larger files take + * the provider's large-file path. Keeping the threshold at the legacy 10 MB cap guarantees + * identical behaviour for existing attachments. + */ +export const INLINE_ATTACHMENT_THRESHOLD_BYTES = INLINE_ATTACHMENT_MAX_BYTES + +export type ProviderFileStrategy = ProviderFileAttachmentStrategy + +/** Large-file delivery strategy for a provider, sourced from its `models.ts` definition. */ +export function getProviderFileStrategy(providerId: ProviderId | string): ProviderFileStrategy { + return getProviderFileAttachment(providerId).strategy +} + +/** True when a file exceeds the inline threshold and the provider has a large-file path. */ +export function shouldUseLargeFilePath( + file: Pick, + providerId: ProviderId | string +): boolean { + if (getProviderFileAttachment(providerId).strategy === 'inline') return false + return Number.isFinite(file.size) && file.size > INLINE_ATTACHMENT_THRESHOLD_BYTES +} + const PDF_MIME_TYPE = 'application/pdf' const DOCUMENT_MIME_TYPES = new Set( @@ -129,8 +164,13 @@ export function supportsFileAttachments(providerId: ProviderId | string): boolea return Boolean(provider && !UNSUPPORTED_FILE_PROVIDERS.has(provider)) } -export function getProviderAttachmentMaxBytes(_providerId: ProviderId | string): number { - return AGENT_ATTACHMENT_MAX_BYTES +/** + * Real maximum attachment size for a provider — its native ceiling when it has a large-file + * path, else the inline base64 threshold. Used for UI limits and validation, never as the + * base64 hydration cap (which stays at {@link INLINE_ATTACHMENT_THRESHOLD_BYTES}). + */ +export function getProviderAttachmentMaxBytes(providerId: ProviderId | string): number { + return getProviderFileAttachment(providerId).maxBytes } export function inferAttachmentMimeType(file: UserFile): string { @@ -315,20 +355,27 @@ export function prepareProviderAttachments( ) } - if (Number.isFinite(file.size) && file.size > AGENT_ATTACHMENT_MAX_BYTES) { + const maxBytes = getProviderAttachmentMaxBytes(providerId) + if (Number.isFinite(file.size) && file.size > maxBytes) { const sizeMB = (file.size / (1024 * 1024)).toFixed(2) - const maxMB = (AGENT_ATTACHMENT_MAX_BYTES / (1024 * 1024)).toFixed(0) + const maxMB = (maxBytes / (1024 * 1024)).toFixed(0) throw new Error( `File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"` ) } - if (!file.base64) { + const providerFileId = file.providerFileId + const providerFileUri = file.providerFileUri + const remoteUrl = file.remoteUrl + const hasHandle = Boolean(providerFileId || providerFileUri || remoteUrl) + + if (!file.base64 && !hasHandle) { throw new Error(`File "${file.name}" could not be read for provider "${providerId}"`) } - const sniffedImageMimeType = contentType === 'image' ? sniffImageMimeType(file.base64) : '' - if (contentType === 'image' && !sniffedImageMimeType) { + const sniffedImageMimeType = + contentType === 'image' && file.base64 ? sniffImageMimeType(file.base64) : '' + if (contentType === 'image' && file.base64 && !sniffedImageMimeType) { throw new Error( `Image bytes in "${file.name}" are not a supported model image format (declared MIME type "${declaredMimeType}"). Supported image formats: image/jpeg, image/png, image/gif, image/webp.` ) @@ -351,10 +398,14 @@ export function prepareProviderAttachments( return { ...attachment, providerMimeType, - dataUrl: toDataUrl(providerMimeType, file.base64), - ...(isTextDocumentMimeType(mimeType) && { - text: decodeBase64Text(file.base64, file.name), - }), + providerFileId, + providerFileUri, + remoteUrl, + ...(file.base64 && { dataUrl: toDataUrl(providerMimeType, file.base64) }), + ...(file.base64 && + isTextDocumentMimeType(mimeType) && { + text: decodeBase64Text(file.base64, file.name), + }), } }) } @@ -378,17 +429,32 @@ export function buildOpenAIMessageContent( for (const attachment of attachments) { if (attachment.contentType === 'image') { - parts.push({ - type: 'input_image', - image_url: attachment.dataUrl, - detail: 'auto', - } satisfies OpenAI.Responses.ResponseInputImage) + parts.push( + attachment.providerFileId + ? ({ + type: 'input_image', + file_id: attachment.providerFileId, + detail: 'auto', + } satisfies OpenAI.Responses.ResponseInputImage) + : ({ + type: 'input_image', + image_url: attachment.dataUrl, + detail: 'auto', + } satisfies OpenAI.Responses.ResponseInputImage) + ) } else { - parts.push({ - type: 'input_file', - filename: attachment.filename, - file_data: attachment.dataUrl, - } satisfies OpenAI.Responses.ResponseInputFile) + parts.push( + attachment.providerFileId + ? ({ + type: 'input_file', + file_id: attachment.providerFileId, + } satisfies OpenAI.Responses.ResponseInputFile) + : ({ + type: 'input_file', + filename: attachment.filename, + file_data: attachment.dataUrl, + } satisfies OpenAI.Responses.ResponseInputFile) + ) } } @@ -409,12 +475,20 @@ export function buildAnthropicMessageContent( if (attachment.contentType === 'image') { parts.push({ type: 'image', - source: { - type: 'base64', - media_type: attachment.providerMimeType as AnthropicImageMediaType, - data: attachment.base64, - }, + source: attachment.remoteUrl + ? ({ type: 'url', url: attachment.remoteUrl } satisfies Anthropic.Messages.URLImageSource) + : ({ + type: 'base64', + media_type: attachment.providerMimeType as AnthropicImageMediaType, + data: attachment.base64 ?? '', + } satisfies Anthropic.Messages.Base64ImageSource), } satisfies Anthropic.Messages.ImageBlockParam) + } else if (attachment.remoteUrl) { + parts.push({ + type: 'document', + source: { type: 'url', url: attachment.remoteUrl }, + title: attachment.filename, + } satisfies Anthropic.Messages.DocumentBlockParam) } else if (attachment.text) { parts.push({ type: 'document', @@ -431,7 +505,7 @@ export function buildAnthropicMessageContent( source: { type: 'base64', media_type: 'application/pdf', - data: attachment.base64, + data: attachment.base64 ?? '', }, title: attachment.filename, } satisfies Anthropic.Messages.DocumentBlockParam) @@ -452,12 +526,21 @@ export function buildGeminiMessageParts( } for (const attachment of prepareProviderAttachments(files, providerId)) { - parts.push({ - inlineData: { - mimeType: attachment.providerMimeType, - data: attachment.base64, - }, - } satisfies Part) + parts.push( + attachment.providerFileUri + ? ({ + fileData: { + fileUri: attachment.providerFileUri, + mimeType: attachment.providerMimeType, + }, + } satisfies Part) + : ({ + inlineData: { + mimeType: attachment.providerMimeType, + data: attachment.base64 ?? '', + }, + } satisfies Part) + ) } return parts @@ -483,7 +566,7 @@ export function buildOpenAICompatibleChatContent( parts.push({ type: 'image_url', image_url: { - url: attachment.dataUrl, + url: attachment.remoteUrl ?? attachment.dataUrl ?? '', }, } satisfies OpenAI.Chat.Completions.ChatCompletionContentPartImage) } @@ -511,14 +594,14 @@ export function buildOpenRouterMessageContent( if (attachment.contentType === 'image') { parts.push({ type: 'image_url', - image_url: { url: attachment.dataUrl }, + image_url: { url: attachment.remoteUrl ?? attachment.dataUrl ?? '' }, } satisfies OpenAI.Chat.Completions.ChatCompletionContentPartImage) } else { parts.push({ type: 'file', file: { filename: attachment.filename, - file_data: attachment.dataUrl, + file_data: attachment.remoteUrl ?? attachment.dataUrl ?? '', }, } satisfies OpenAI.Chat.Completions.ChatCompletionContentPart.File) } @@ -554,7 +637,7 @@ export function buildBedrockMessageContent( } for (const attachment of prepareProviderAttachments(files, providerId)) { - const bytes = Buffer.from(attachment.base64, 'base64') + const bytes = Buffer.from(attachment.base64 ?? '', 'base64') if (attachment.contentType === 'image') { parts.push({ image: { diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts new file mode 100644 index 00000000000..9704f3e2f2f --- /dev/null +++ b/apps/sim/providers/file-attachments.server.ts @@ -0,0 +1,187 @@ +import { FileState, GoogleGenAI } from '@google/genai' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import type { StorageContext } from '@/lib/uploads' +import { StorageService } from '@/lib/uploads' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' +import type { UserFile } from '@/executor/types' +import { + getProviderFileStrategy, + inferAttachmentMimeType, + shouldUseLargeFilePath, +} from '@/providers/attachments' +import type { Message, ProviderId, ProviderRequest } from '@/providers/types' + +const logger = createLogger('ProviderFileAttachments') + +const OPENAI_FILES_ENDPOINT = 'https://api.openai.com/v1/files' +const PRESIGNED_URL_EXPIRY_SECONDS = 60 * 60 +/** OpenAI auto-deletes uploaded files after this window — see the "rely on provider expiry" lifecycle. */ +const OPENAI_FILE_EXPIRY_SECONDS = 60 * 60 +const GEMINI_POLL_INTERVAL_MS = 1000 +const GEMINI_PROCESSING_TIMEOUT_MS = 5 * 60_000 + +interface RemoteUrlContext { + requestId: string + userId?: string +} + +/** + * Mints a short-lived signed download URL for every attachment that exceeds the inline + * threshold on a large-file-capable provider, storing it on `file.remoteUrl`. Providers + * with a `remote-url` strategy use it directly; `files-api` providers upload from it later. + * Requires cloud storage — without it large files fall back to the (capped) base64 path. + */ +export async function attachLargeFileRemoteUrls( + files: UserFile[] | undefined, + providerId: ProviderId | string, + ctx: RemoteUrlContext +): Promise { + if (!files?.length || getProviderFileStrategy(providerId) === 'inline') return + + for (const file of files) { + file.providerFileId = undefined + file.providerFileUri = undefined + file.remoteUrl = undefined + + if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue + if (!StorageService.hasCloudStorage()) continue + + if (!ctx.userId) { + throw new Error( + `File "${file.name}" requires an authenticated user for provider "${providerId}"` + ) + } + + const context = (file.context as StorageContext) || inferContextFromKey(file.key) + const hasAccess = await verifyFileAccess(file.key, ctx.userId, undefined, context, false) + if (!hasAccess) { + throw new Error(`File "${file.name}" is not accessible for provider "${providerId}"`) + } + + file.remoteUrl = await StorageService.generatePresignedDownloadUrl( + file.key, + context, + PRESIGNED_URL_EXPIRY_SECONDS + ) + } +} + +/** + * For `files-api` providers, uploads each large attachment (already carrying a signed + * `remoteUrl`) to the provider Files API and records the returned handle on the file. + * Runs after the request's API key is resolved so hosted and BYOK keys both work. + */ +export async function uploadLargeFilesToProvider( + request: ProviderRequest, + providerId: ProviderId | string +): Promise { + if (getProviderFileStrategy(providerId) !== 'files-api') return + + const groups = groupUploadableFiles(request.messages) + if (groups.length === 0) return + + const ai = providerId === 'google' ? new GoogleGenAI({ apiKey: request.apiKey }) : null + + for (const group of groups) { + const [representative] = group + if (providerId === 'openai') { + await uploadOpenAIFile(representative, request.apiKey, request.abortSignal) + } else if (ai) { + await uploadGeminiFile(representative, ai, request.abortSignal) + } + for (const file of group) { + file.providerFileId = representative.providerFileId + file.providerFileUri = representative.providerFileUri + } + } +} + +/** + * Groups large files needing a Files API upload by storage key so a file referenced across + * multiple messages uploads once; the resulting handle is then applied to every occurrence. + */ +function groupUploadableFiles(messages: Message[] | undefined): UserFile[][] { + const groups = new Map() + for (const message of messages ?? []) { + for (const file of message.files ?? []) { + if (!file.remoteUrl || file.providerFileId || file.providerFileUri) continue + const dedupeKey = file.key || file.remoteUrl + const group = groups.get(dedupeKey) + if (group) group.push(file) + else groups.set(dedupeKey, [file]) + } + } + return [...groups.values()] +} + +async function fetchRemoteFileBlob(file: UserFile, signal?: AbortSignal): Promise { + const response = await fetch(file.remoteUrl as string, { signal }) + if (!response.ok) { + throw new Error(`Failed to download "${file.name}" for upload (status ${response.status})`) + } + return response.blob() +} + +async function uploadOpenAIFile( + file: UserFile, + apiKey: string | undefined, + signal?: AbortSignal +): Promise { + const mimeType = inferAttachmentMimeType(file) + const blob = await fetchRemoteFileBlob(file, signal) + + const form = new FormData() + form.append('purpose', mimeType.startsWith('image/') ? 'vision' : 'user_data') + form.append('expires_after[anchor]', 'created_at') + form.append('expires_after[seconds]', String(OPENAI_FILE_EXPIRY_SECONDS)) + form.append('file', blob, file.name) + + const response = await fetch(OPENAI_FILES_ENDPOINT, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + body: form, + signal, + }) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`OpenAI file upload failed for "${file.name}" (${response.status}): ${detail}`) + } + + const uploaded = (await response.json()) as { id?: string } + if (!uploaded.id) { + throw new Error(`OpenAI file upload for "${file.name}" returned no id`) + } + file.providerFileId = uploaded.id + logger.info(`Uploaded "${file.name}" to OpenAI Files API`, { fileId: uploaded.id }) +} + +async function uploadGeminiFile( + file: UserFile, + ai: GoogleGenAI, + signal?: AbortSignal +): Promise { + const mimeType = inferAttachmentMimeType(file) + const blob = await fetchRemoteFileBlob(file, signal) + + let uploaded = await ai.files.upload({ file: blob, config: { mimeType, abortSignal: signal } }) + + const deadline = Date.now() + GEMINI_PROCESSING_TIMEOUT_MS + while (uploaded.state === FileState.PROCESSING) { + if (Date.now() > deadline) { + throw new Error(`Gemini file processing timed out for "${file.name}"`) + } + await sleep(GEMINI_POLL_INTERVAL_MS) + uploaded = await ai.files.get({ name: uploaded.name as string }) + } + + if (uploaded.state === FileState.FAILED || !uploaded.uri) { + throw new Error( + `Gemini file processing failed for "${file.name}": ${getErrorMessage(uploaded.error, 'unknown error')}` + ) + } + file.providerFileUri = uploaded.uri + logger.info(`Uploaded "${file.name}" to Gemini File API`, { fileUri: uploaded.uri }) +} diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 26433940e33..d376c28a070 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' import { getApiKeyWithBYOK } from '@/lib/api-key/byok' import { getCostMultiplier } from '@/lib/core/config/env-flags' import type { StreamingExecution } from '@/executor/types' +import { uploadLargeFilesToProvider } from '@/providers/file-attachments.server' import { getProviderExecutor } from '@/providers/registry' import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' import { @@ -190,6 +191,8 @@ export async function executeProviderRequest( } } + await uploadLargeFilesToProvider(sanitizedRequest, providerId) + const response = await provider.executeRequest(sanitizedRequest) if (isStreamingExecution(response)) { diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index fc6c172aac0..45077884ba0 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -81,6 +81,34 @@ export interface ProviderDefinition { isReseller?: boolean capabilities?: ModelCapabilities contextInformationAvailable?: boolean + /** Agent-block file attachment limit and large-file delivery for this provider. */ + fileAttachment?: ProviderFileAttachment +} + +/** + * How a provider accepts agent-block attachments larger than the inline base64 threshold: + * `files-api` uploads to the provider Files API, `remote-url` passes a signed URL the + * provider fetches itself, `inline` means base64-only (no large-file path). + */ +export type ProviderFileAttachmentStrategy = 'inline' | 'files-api' | 'remote-url' + +export interface ProviderFileAttachment { + /** Maximum attachment size the provider accepts, in bytes. */ + maxBytes: number + strategy: ProviderFileAttachmentStrategy +} + +/** Inline base64 attachment cap, also the fallback limit for providers without a large-file path. */ +export const INLINE_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 + +const DEFAULT_FILE_ATTACHMENT: ProviderFileAttachment = { + maxBytes: INLINE_ATTACHMENT_MAX_BYTES, + strategy: 'inline', +} + +/** Provider-level attachment limit + strategy, keyed on the granular provider id. */ +export function getProviderFileAttachment(providerId: string): ProviderFileAttachment { + return PROVIDER_DEFINITIONS[providerId]?.fileAttachment ?? DEFAULT_FILE_ATTACHMENT } export const PROVIDER_DEFINITIONS: Record = { @@ -102,6 +130,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, together: { id: 'together', + fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' }, name: 'Together AI', description: 'Fast inference for open-source models via Together AI', defaultModel: '', @@ -118,6 +147,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, baseten: { id: 'baseten', + fileAttachment: { maxBytes: 25 * 1024 * 1024, strategy: 'remote-url' }, name: 'Baseten', description: 'Fast inference for open-source models via Baseten Model APIs', defaultModel: '', @@ -134,6 +164,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, openrouter: { id: 'openrouter', + fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' }, name: 'OpenRouter', description: 'Unified access to many models via OpenRouter', defaultModel: '', @@ -164,6 +195,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, vllm: { id: 'vllm', + fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' }, name: 'vLLM', icon: VllmIcon, description: 'Self-hosted vLLM with an OpenAI-compatible API', @@ -191,6 +223,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, openai: { id: 'openai', + fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'files-api' }, name: 'OpenAI', description: "OpenAI's models", defaultModel: 'gpt-4.1', @@ -624,6 +657,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, anthropic: { id: 'anthropic', + fileAttachment: { maxBytes: 100 * 1024 * 1024, strategy: 'remote-url' }, name: 'Anthropic', description: "Anthropic's Claude models", defaultModel: 'claude-sonnet-4-6', @@ -1285,6 +1319,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, google: { id: 'google', + fileAttachment: { maxBytes: 100 * 1024 * 1024, strategy: 'files-api' }, name: 'Google', description: "Google's Gemini models", defaultModel: 'gemini-2.5-pro', @@ -1741,6 +1776,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, xai: { id: 'xai', + fileAttachment: { maxBytes: 20 * 1024 * 1024, strategy: 'remote-url' }, name: 'xAI', description: "xAI's Grok models", defaultModel: 'grok-4.3', @@ -2013,6 +2049,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, groq: { id: 'groq', + fileAttachment: { maxBytes: 20 * 1024 * 1024, strategy: 'remote-url' }, name: 'Groq', description: "Groq's LLM models with high-performance inference", defaultModel: 'groq/llama-3.3-70b-versatile', From 6acb47ebbe211666f78fb1972b5262e9ff46c49d Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:07:37 -0700 Subject: [PATCH 02/16] fix(providers): clear forged file handles for inline providers too attachLargeFileRemoteUrls early-returned for inline-strategy providers before clearing server-only handle fields, so a forged remoteUrl on an inline-provider file could still reach a builder (e.g. buildOpenAICompatibleChatContent for mistral/ollama). Clear the handles for every provider before the strategy check. --- apps/sim/providers/file-attachments.server.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 9704f3e2f2f..3f5ff16e4c1 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -33,19 +33,26 @@ interface RemoteUrlContext { * threshold on a large-file-capable provider, storing it on `file.remoteUrl`. Providers * with a `remote-url` strategy use it directly; `files-api` providers upload from it later. * Requires cloud storage — without it large files fall back to the (capped) base64 path. + * + * The server-only handle fields are first cleared on every file for every provider + * (including inline) so a forged handle on untrusted input can never reach a builder. */ export async function attachLargeFileRemoteUrls( files: UserFile[] | undefined, providerId: ProviderId | string, ctx: RemoteUrlContext ): Promise { - if (!files?.length || getProviderFileStrategy(providerId) === 'inline') return + if (!files?.length) return for (const file of files) { file.providerFileId = undefined file.providerFileUri = undefined file.remoteUrl = undefined + } + + if (getProviderFileStrategy(providerId) === 'inline') return + for (const file of files) { if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue if (!StorageService.hasCloudStorage()) continue From 79780317c3811cb757da8b37af8f8cbb1e7c6a1c Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:12:52 -0700 Subject: [PATCH 03/16] fix(providers): correct OpenAI expiry serialization and Anthropic large-text-doc handling - OpenAI upload now uses the SDK (client.files.create) so expires_after is serialized as a real nested object; the prior expires_after[anchor] bracket FormData keys were ignored by OpenAI's server, leaving files un-expiring. - Anthropic url document source only supports PDFs/images; large non-PDF text docs now throw a clear error instead of emitting an unsupported url source. - Warn when an oversized file can't be sent because cloud storage is unavailable. --- apps/sim/providers/attachments.test.ts | 34 ++++++++++++++ apps/sim/providers/attachments.ts | 5 +++ apps/sim/providers/file-attachments.server.ts | 44 ++++++++----------- 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index bca5326f090..e141a292505 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -361,6 +361,40 @@ describe('provider large-file capability', () => { ]) }) + it('rejects oversized non-PDF text documents on Anthropic (url source supports PDFs/images only)', () => { + expect(() => + buildAnthropicMessageContent( + 'Analyze', + [ + { + ...markdownFile, + type: 'text/csv', + name: 'data.csv', + base64: undefined, + remoteUrl: 'https://signed/data.csv', + }, + ], + 'anthropic' + ) + ).toThrow('Only PDFs and images are supported') + }) + + it('references large Anthropic PDFs via a url document source', () => { + const content = buildAnthropicMessageContent( + 'Analyze', + [{ ...pdfFile, base64: undefined, remoteUrl: 'https://signed/doc.pdf' }], + 'anthropic' + ) + expect(content).toEqual([ + { type: 'text', text: 'Analyze' }, + { + type: 'document', + source: { type: 'url', url: 'https://signed/doc.pdf' }, + title: 'example.pdf', + }, + ]) + }) + it('rejects files above the provider ceiling', () => { const huge = { ...imageFile, diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index 69e7d0e196a..6be9fb6b91f 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -484,6 +484,11 @@ export function buildAnthropicMessageContent( } satisfies Anthropic.Messages.Base64ImageSource), } satisfies Anthropic.Messages.ImageBlockParam) } else if (attachment.remoteUrl) { + if (attachment.mimeType !== PDF_MIME_TYPE) { + throw new Error( + `Document "${attachment.filename}" (${attachment.mimeType}) is too large to send to provider "${providerId}". Only PDFs and images are supported above the inline limit — convert it to PDF or reduce its size.` + ) + } parts.push({ type: 'document', source: { type: 'url', url: attachment.remoteUrl }, diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 3f5ff16e4c1..92554cffb65 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -2,6 +2,7 @@ import { FileState, GoogleGenAI } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' +import OpenAI, { toFile } from 'openai' import type { StorageContext } from '@/lib/uploads' import { StorageService } from '@/lib/uploads' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' @@ -16,7 +17,6 @@ import type { Message, ProviderId, ProviderRequest } from '@/providers/types' const logger = createLogger('ProviderFileAttachments') -const OPENAI_FILES_ENDPOINT = 'https://api.openai.com/v1/files' const PRESIGNED_URL_EXPIRY_SECONDS = 60 * 60 /** OpenAI auto-deletes uploaded files after this window — see the "rely on provider expiry" lifecycle. */ const OPENAI_FILE_EXPIRY_SECONDS = 60 * 60 @@ -54,7 +54,12 @@ export async function attachLargeFileRemoteUrls( for (const file of files) { if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue - if (!StorageService.hasCloudStorage()) continue + if (!StorageService.hasCloudStorage()) { + logger.warn( + `[${ctx.requestId}] "${file.name}" exceeds the inline limit for "${providerId}" but cloud storage is unavailable; it cannot be sent` + ) + continue + } if (!ctx.userId) { throw new Error( @@ -90,12 +95,13 @@ export async function uploadLargeFilesToProvider( const groups = groupUploadableFiles(request.messages) if (groups.length === 0) return + const openai = providerId === 'openai' ? new OpenAI({ apiKey: request.apiKey }) : null const ai = providerId === 'google' ? new GoogleGenAI({ apiKey: request.apiKey }) : null for (const group of groups) { const [representative] = group - if (providerId === 'openai') { - await uploadOpenAIFile(representative, request.apiKey, request.abortSignal) + if (openai) { + await uploadOpenAIFile(representative, openai, request.abortSignal) } else if (ai) { await uploadGeminiFile(representative, ai, request.abortSignal) } @@ -134,33 +140,21 @@ async function fetchRemoteFileBlob(file: UserFile, signal?: AbortSignal): Promis async function uploadOpenAIFile( file: UserFile, - apiKey: string | undefined, + client: OpenAI, signal?: AbortSignal ): Promise { const mimeType = inferAttachmentMimeType(file) const blob = await fetchRemoteFileBlob(file, signal) - const form = new FormData() - form.append('purpose', mimeType.startsWith('image/') ? 'vision' : 'user_data') - form.append('expires_after[anchor]', 'created_at') - form.append('expires_after[seconds]', String(OPENAI_FILE_EXPIRY_SECONDS)) - form.append('file', blob, file.name) - - const response = await fetch(OPENAI_FILES_ENDPOINT, { - method: 'POST', - headers: { Authorization: `Bearer ${apiKey}` }, - body: form, - signal, - }) - if (!response.ok) { - const detail = await response.text().catch(() => '') - throw new Error(`OpenAI file upload failed for "${file.name}" (${response.status}): ${detail}`) - } + const uploaded = await client.files.create( + { + file: await toFile(blob, file.name, { type: mimeType }), + purpose: mimeType.startsWith('image/') ? 'vision' : 'user_data', + expires_after: { anchor: 'created_at', seconds: OPENAI_FILE_EXPIRY_SECONDS }, + }, + { signal } + ) - const uploaded = (await response.json()) as { id?: string } - if (!uploaded.id) { - throw new Error(`OpenAI file upload for "${file.name}" returned no id`) - } file.providerFileId = uploaded.id logger.info(`Uploaded "${file.name}" to OpenAI Files API`, { fileId: uploaded.id }) } From 48f79a4e457c7ad90b4547bc59abdf15dac1d666 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:32:36 -0700 Subject: [PATCH 04/16] fix(providers): harden large-file path (SSRF fetch, ceiling gate, per-file UI limit) - Download files for OpenAI/Gemini uploads via validateUrlWithDNS + IP-pinned fetch so a forged URL can't reach internal addresses (covers all callers). - Reject files above the provider ceiling before downloading/uploading. - UI now validates each file against the provider's per-file ceiling instead of summing all files against it, matching server-side per-file validation. - Lower Anthropic ceiling to 50MB (documented 32MB request cap / page limits). --- .../components/file-upload/file-upload.tsx | 9 +-- apps/sim/providers/file-attachments.server.ts | 59 ++++++++++++++++--- apps/sim/providers/models.ts | 2 +- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 0b4cbea70ba..20eac54e441 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -292,24 +292,19 @@ export function FileUpload({ const files = e.target.files if (!files || files.length === 0) return - const existingFiles = Array.isArray(value) ? value : value ? [value] : [] - const existingTotalSize = existingFiles.reduce((sum, file) => sum + file.size, 0) - const validFiles: File[] = [] - let totalNewSize = 0 let sizeExceededFile: string | null = null for (let i = 0; i < files.length; i++) { const file = files[i] - if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) { - const errorMessage = `Adding ${file.name} would exceed the maximum size limit of ${maxSizeLabel}` + if (file.size > maxSizeInBytes) { + const errorMessage = `${file.name} exceeds the maximum file size of ${maxSizeLabel}` logger.error(errorMessage, activeWorkflowId) if (!sizeExceededFile) { sizeExceededFile = errorMessage } } else { validFiles.push(file) - totalNewSize += file.size } } diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 92554cffb65..1ae569352b8 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -3,12 +3,18 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import OpenAI, { toFile } from 'openai' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits' import type { StorageContext } from '@/lib/uploads' import { StorageService } from '@/lib/uploads' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import { + getProviderAttachmentMaxBytes, getProviderFileStrategy, inferAttachmentMimeType, shouldUseLargeFilePath, @@ -54,6 +60,16 @@ export async function attachLargeFileRemoteUrls( for (const file of files) { if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue + + const maxBytes = getProviderAttachmentMaxBytes(providerId) + if (Number.isFinite(file.size) && file.size > maxBytes) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(2) + const maxMB = (maxBytes / (1024 * 1024)).toFixed(0) + throw new Error( + `File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"` + ) + } + if (!StorageService.hasCloudStorage()) { logger.warn( `[${ctx.requestId}] "${file.name}" exceeds the inline limit for "${providerId}" but cloud storage is unavailable; it cannot be sent` @@ -95,15 +111,16 @@ export async function uploadLargeFilesToProvider( const groups = groupUploadableFiles(request.messages) if (groups.length === 0) return + const maxBytes = getProviderAttachmentMaxBytes(providerId) const openai = providerId === 'openai' ? new OpenAI({ apiKey: request.apiKey }) : null const ai = providerId === 'google' ? new GoogleGenAI({ apiKey: request.apiKey }) : null for (const group of groups) { const [representative] = group if (openai) { - await uploadOpenAIFile(representative, openai, request.abortSignal) + await uploadOpenAIFile(representative, openai, maxBytes, request.abortSignal) } else if (ai) { - await uploadGeminiFile(representative, ai, request.abortSignal) + await uploadGeminiFile(representative, ai, maxBytes, request.abortSignal) } for (const file of group) { file.providerFileId = representative.providerFileId @@ -130,21 +147,48 @@ function groupUploadableFiles(messages: Message[] | undefined): UserFile[][] { return [...groups.values()] } -async function fetchRemoteFileBlob(file: UserFile, signal?: AbortSignal): Promise { - const response = await fetch(file.remoteUrl as string, { signal }) +/** + * Downloads the file from its signed URL with DNS validation and IP pinning so a URL that + * somehow resolves to an internal address can never be fetched (SSRF defense for every + * caller, not just the agent path). Bounded by the provider's attachment ceiling. + */ +async function fetchRemoteFileBlob( + file: UserFile, + maxBytes: number, + signal?: AbortSignal +): Promise { + const url = file.remoteUrl as string + const validation = await validateUrlWithDNS(url, 'fileUrl') + if (!validation.isValid || !validation.resolvedIP) { + throw new Error( + `Cannot download "${file.name}" for upload: ${validation.error || 'invalid URL'}` + ) + } + + const response = await secureFetchWithPinnedIP(url, validation.resolvedIP, { + maxResponseBytes: maxBytes, + signal, + }) if (!response.ok) { throw new Error(`Failed to download "${file.name}" for upload (status ${response.status})`) } - return response.blob() + + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes, + label: 'provider file upload', + signal, + }) + return new Blob([buffer], { type: file.type || inferAttachmentMimeType(file) }) } async function uploadOpenAIFile( file: UserFile, client: OpenAI, + maxBytes: number, signal?: AbortSignal ): Promise { const mimeType = inferAttachmentMimeType(file) - const blob = await fetchRemoteFileBlob(file, signal) + const blob = await fetchRemoteFileBlob(file, maxBytes, signal) const uploaded = await client.files.create( { @@ -162,10 +206,11 @@ async function uploadOpenAIFile( async function uploadGeminiFile( file: UserFile, ai: GoogleGenAI, + maxBytes: number, signal?: AbortSignal ): Promise { const mimeType = inferAttachmentMimeType(file) - const blob = await fetchRemoteFileBlob(file, signal) + const blob = await fetchRemoteFileBlob(file, maxBytes, signal) let uploaded = await ai.files.upload({ file: blob, config: { mimeType, abortSignal: signal } }) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 45077884ba0..9381b7317ea 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -657,7 +657,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, anthropic: { id: 'anthropic', - fileAttachment: { maxBytes: 100 * 1024 * 1024, strategy: 'remote-url' }, + fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' }, name: 'Anthropic', description: "Anthropic's Claude models", defaultModel: 'claude-sonnet-4-6', From 9eda8616b485d357f437a319fc26f020bf74d822 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:39:08 -0700 Subject: [PATCH 05/16] refactor(providers): read files-api upload bytes via storage SDK Read OpenAI/Gemini upload bytes through downloadFileFromStorage instead of HTTP-fetching the presigned URL. Removes any server-side URL fetch (no SSRF vector) and works with internal object storage (e.g. self-hosted MinIO), which an IP-pinned URL fetch would have blocked. --- apps/sim/providers/file-attachments.server.ts | 44 ++++--------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 1ae569352b8..1e135de86df 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -3,14 +3,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import OpenAI, { toFile } from 'openai' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' -import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits' import type { StorageContext } from '@/lib/uploads' import { StorageService } from '@/lib/uploads' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import { @@ -148,36 +144,12 @@ function groupUploadableFiles(messages: Message[] | undefined): UserFile[][] { } /** - * Downloads the file from its signed URL with DNS validation and IP pinning so a URL that - * somehow resolves to an internal address can never be fetched (SSRF defense for every - * caller, not just the agent path). Bounded by the provider's attachment ceiling. + * Reads the file bytes straight from storage via the storage SDK (not by HTTP-fetching the + * signed URL), so there is no server-side URL fetch to be an SSRF vector and internal + * object storage works. Bounded by the provider's attachment ceiling. */ -async function fetchRemoteFileBlob( - file: UserFile, - maxBytes: number, - signal?: AbortSignal -): Promise { - const url = file.remoteUrl as string - const validation = await validateUrlWithDNS(url, 'fileUrl') - if (!validation.isValid || !validation.resolvedIP) { - throw new Error( - `Cannot download "${file.name}" for upload: ${validation.error || 'invalid URL'}` - ) - } - - const response = await secureFetchWithPinnedIP(url, validation.resolvedIP, { - maxResponseBytes: maxBytes, - signal, - }) - if (!response.ok) { - throw new Error(`Failed to download "${file.name}" for upload (status ${response.status})`) - } - - const buffer = await readResponseToBufferWithLimit(response, { - maxBytes, - label: 'provider file upload', - signal, - }) +async function downloadFileForUpload(file: UserFile, maxBytes: number): Promise { + const buffer = await downloadFileFromStorage(file, 'provider-file-upload', logger, { maxBytes }) return new Blob([buffer], { type: file.type || inferAttachmentMimeType(file) }) } @@ -188,7 +160,7 @@ async function uploadOpenAIFile( signal?: AbortSignal ): Promise { const mimeType = inferAttachmentMimeType(file) - const blob = await fetchRemoteFileBlob(file, maxBytes, signal) + const blob = await downloadFileForUpload(file, maxBytes) const uploaded = await client.files.create( { @@ -210,7 +182,7 @@ async function uploadGeminiFile( signal?: AbortSignal ): Promise { const mimeType = inferAttachmentMimeType(file) - const blob = await fetchRemoteFileBlob(file, maxBytes, signal) + const blob = await downloadFileForUpload(file, maxBytes) let uploaded = await ai.files.upload({ file: blob, config: { mimeType, abortSignal: signal } }) From e47d983680686a4fb002acb5e5c5aeb4512ec730 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:39:54 -0700 Subject: [PATCH 06/16] docs(providers): clarify files-api bytes are read from storage at upload time --- apps/sim/providers/file-attachments.server.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 1e135de86df..5b1920dba9c 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -31,10 +31,11 @@ interface RemoteUrlContext { } /** - * Mints a short-lived signed download URL for every attachment that exceeds the inline - * threshold on a large-file-capable provider, storing it on `file.remoteUrl`. Providers - * with a `remote-url` strategy use it directly; `files-api` providers upload from it later. - * Requires cloud storage — without it large files fall back to the (capped) base64 path. + * Resolves every attachment that exceeds the inline threshold on a large-file-capable + * provider to a short-lived signed URL on `file.remoteUrl`. `remote-url` providers send it + * to the model directly; for `files-api` providers it marks the file for upload (the bytes + * are read from storage at upload time). Requires cloud storage — otherwise large files + * fall back to the (capped) base64 path. * * The server-only handle fields are first cleared on every file for every provider * (including inline) so a forged handle on untrusted input can never reach a builder. From d86041b613dc7a89f74d116b383da03e1d9e408a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:53:05 -0700 Subject: [PATCH 07/16] fix(providers): enforce access checks and strip forged ids in the upload path uploadLargeFilesToProvider runs on raw request messages for every caller (incl. the internal providers passthrough), so harden it independently of the agent path: - verifyFileAccess on each file's storage key before reading its bytes, so a forged key can't exfiltrate another user's file. - clear any inbound providerFileId/providerFileUri up front (legit ids are only set by the upload itself), so a forged id can't reference a file in a hosted account. --- apps/sim/providers/file-attachments.server.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 5b1920dba9c..a2f1c08f3a3 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -98,6 +98,10 @@ export async function attachLargeFileRemoteUrls( * For `files-api` providers, uploads each large attachment (already carrying a signed * `remoteUrl`) to the provider Files API and records the returned handle on the file. * Runs after the request's API key is resolved so hosted and BYOK keys both work. + * + * Any `providerFileId`/`providerFileUri` present on input is cleared first — legitimate ids + * are only assigned by the upload below, so a forged id (which could otherwise reference an + * arbitrary file in a shared hosted provider account) can never reach a message builder. */ export async function uploadLargeFilesToProvider( request: ProviderRequest, @@ -105,6 +109,13 @@ export async function uploadLargeFilesToProvider( ): Promise { if (getProviderFileStrategy(providerId) !== 'files-api') return + for (const message of request.messages ?? []) { + for (const file of message.files ?? []) { + file.providerFileId = undefined + file.providerFileUri = undefined + } + } + const groups = groupUploadableFiles(request.messages) if (groups.length === 0) return @@ -114,6 +125,7 @@ export async function uploadLargeFilesToProvider( for (const group of groups) { const [representative] = group + await assertFileAccessForUpload(representative, request.userId) if (openai) { await uploadOpenAIFile(representative, openai, maxBytes, request.abortSignal) } else if (ai) { @@ -126,6 +138,28 @@ export async function uploadLargeFilesToProvider( } } +/** + * Verifies the caller may read this file before its bytes are uploaded to a provider. Enforced + * for every caller of {@link uploadLargeFilesToProvider} (not just the agent path), so a forged + * storage key in a passthrough request cannot exfiltrate another user's file. + */ +async function assertFileAccessForUpload( + file: UserFile, + userId: string | undefined +): Promise { + if (!file.key) { + throw new Error(`File "${file.name}" has no storage key`) + } + if (!userId) { + throw new Error(`File "${file.name}" requires an authenticated user to upload`) + } + const context = (file.context as StorageContext) || inferContextFromKey(file.key) + const hasAccess = await verifyFileAccess(file.key, userId, undefined, context, false) + if (!hasAccess) { + throw new Error(`File "${file.name}" is not accessible`) + } +} + /** * Groups large files needing a Files API upload by storage key so a file referenced across * multiple messages uploads once; the resulting handle is then applied to every occurrence. From 9c4ad83130fc1b61fc4e8d3a613f158beca35c1e Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 22:58:33 -0700 Subject: [PATCH 08/16] fix(providers): resolve UI attachment limit with the same model->provider helper as execution The file-upload control imported getProviderFromModel from @/providers/models, but the execution path and every other consumer use the one in @/providers/utils (runtime registry + reseller patterns). Align the UI so its size cap can't disagree with server-side validation for reseller or dynamically-listed models. --- .../components/sub-block/components/file-upload/file-upload.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 20eac54e441..dc6d65c9382 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -25,7 +25,7 @@ import { workspaceFilesKeys, } from '@/hooks/queries/workspace-files' import { getProviderAttachmentMaxBytes } from '@/providers/attachments' -import { getProviderFromModel } from '@/providers/models' +import { getProviderFromModel } from '@/providers/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' From fc9d1cd1cd35daf2e6adf64f005f66ea34db4a6e Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:06:59 -0700 Subject: [PATCH 09/16] test(providers): add new models.ts exports to provider mocks attachments.ts now reads getProviderFileAttachment / INLINE_ATTACHMENT_MAX_BYTES from @/providers/models; the provider unit tests that fully mock that module need both exports or attachments.ts fails to load. --- apps/sim/blocks/utils.test.ts | 4 ++++ apps/sim/lib/api-key/byok.test.ts | 4 ++++ apps/sim/providers/azure-anthropic/index.test.ts | 4 ++++ apps/sim/providers/azure-openai/index.test.ts | 4 ++++ apps/sim/providers/baseten/index.test.ts | 4 ++++ apps/sim/providers/bedrock/index.test.ts | 4 ++++ apps/sim/providers/fireworks/index.test.ts | 4 ++++ apps/sim/providers/litellm/index.test.ts | 4 ++++ apps/sim/providers/ollama-cloud/index.test.ts | 4 ++++ apps/sim/providers/openrouter/index.test.ts | 4 ++++ apps/sim/providers/together/index.test.ts | 4 ++++ apps/sim/providers/vllm/index.test.ts | 4 ++++ 12 files changed, 48 insertions(+) diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 85b2e273960..41cc478ad2b 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -47,6 +47,10 @@ vi.mock('@/lib/core/config/env-flags', () => ({ })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getHostedModels: mockGetHostedModels, getProviderModels: mockGetProviderModels, getProviderIcon: mockGetProviderIcon, diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index 6c1fcba13f0..439c392d946 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -40,6 +40,10 @@ vi.mock('@/lib/core/config/env-flags', () => ({ })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getHostedModels: vi.fn(() => []), })) diff --git a/apps/sim/providers/azure-anthropic/index.test.ts b/apps/sim/providers/azure-anthropic/index.test.ts index b5254f9eaf8..d78c9bdac2b 100644 --- a/apps/sim/providers/azure-anthropic/index.test.ts +++ b/apps/sim/providers/azure-anthropic/index.test.ts @@ -44,6 +44,10 @@ vi.mock('@/providers/anthropic/core', () => ({ executeAnthropicProviderRequest: mockExecuteAnthropic, })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn(() => []), getProviderDefaultModel: vi.fn(() => 'azure-anthropic/claude'), })) diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index 7e18ea809df..15e4073e8b0 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -62,6 +62,10 @@ vi.mock('@/providers/azure-openai/utils', () => ({ checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn(() => []), getProviderDefaultModel: vi.fn(() => 'azure/gpt-4o'), })) diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index 6a8c2bd6d81..df296c6626e 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -26,6 +26,10 @@ vi.mock('openai', () => ({ vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('openai/gpt-oss-120b'), })) diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index aaf09ae6fb8..38cb857425e 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -25,6 +25,10 @@ vi.mock('@/providers/bedrock/utils', () => ({ })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('us.anthropic.claude-3-5-sonnet-20241022-v2:0'), })) diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index bb7fef32590..8c38a5b7303 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -26,6 +26,10 @@ vi.mock('openai', () => ({ vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('llama-v3p1-70b-instruct'), })) diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 8365d4042c2..8a6a2fa011d 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -29,6 +29,10 @@ vi.mock('@/stores/providers', () => ({ })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: () => [], getProviderDefaultModel: () => '', })) diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index 9eb416b8261..1164e0be3e3 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -45,6 +45,10 @@ vi.mock('openai', () => { vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue(''), })) diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index 0d0a667ccf0..8c26f611b8c 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -37,6 +37,10 @@ vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 10 })) vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue(''), })) diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index 6e52dd0d268..9d5386331cc 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -26,6 +26,10 @@ vi.mock('openai', () => ({ vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('moonshotai/Kimi-K2-Instruct'), })) diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index c95f5297f1e..8739c95f989 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -51,6 +51,10 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({ })) vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn(() => []), getProviderDefaultModel: vi.fn(() => 'vllm/generic'), })) From d8e1c257d0cd1450365b5497619c8d27abdd61e6 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:08:19 -0700 Subject: [PATCH 10/16] fix(providers): guard Gemini upload response name before polling ai.files.upload returns name as string | undefined; guard it (instead of an as-string cast) so a missing name surfaces a clear error at the upload site rather than an opaque files.get failure on the first poll. --- apps/sim/providers/file-attachments.server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index a2f1c08f3a3..8b9c9ebf2a8 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -220,6 +220,10 @@ async function uploadGeminiFile( const blob = await downloadFileForUpload(file, maxBytes) let uploaded = await ai.files.upload({ file: blob, config: { mimeType, abortSignal: signal } }) + if (!uploaded.name) { + throw new Error(`Gemini upload for "${file.name}" returned no file name`) + } + const uploadedName = uploaded.name const deadline = Date.now() + GEMINI_PROCESSING_TIMEOUT_MS while (uploaded.state === FileState.PROCESSING) { @@ -227,7 +231,7 @@ async function uploadGeminiFile( throw new Error(`Gemini file processing timed out for "${file.name}"`) } await sleep(GEMINI_POLL_INTERVAL_MS) - uploaded = await ai.files.get({ name: uploaded.name as string }) + uploaded = await ai.files.get({ name: uploadedName }) } if (uploaded.state === FileState.FAILED || !uploaded.uri) { From 4097df23c0ca38d9473fefc02a4c63e089562be8 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:22:10 -0700 Subject: [PATCH 11/16] fix(uploads): type the file-handle key list so omit preserves UserFile fields The 'as const' readonly tuple widened omit's K to all keys, collapsing Omit to {} and failing the production build's type check. Declare the array as Array so K is the precise literal union. --- apps/sim/lib/uploads/utils/file-utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index ac61f46d774..6560c94e786 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -701,7 +701,11 @@ function resolveInternalFileUrl(file: RawFileInput): string { * Provider large-file handles are populated by the server pipeline and must never be * accepted from untrusted file input (they drive server-side fetch/upload). */ -const PROVIDER_FILE_HANDLE_FIELDS = ['providerFileId', 'providerFileUri', 'remoteUrl'] as const +const PROVIDER_FILE_HANDLE_FIELDS: Array<'providerFileId' | 'providerFileUri' | 'remoteUrl'> = [ + 'providerFileId', + 'providerFileUri', + 'remoteUrl', +] /** * Core conversion logic from RawFileInput to UserFile From 0b2e3a8525c8b37c96ee7d406ca4070354e6253d Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:22:11 -0700 Subject: [PATCH 12/16] refactor(providers): run handle-clear + URL-mint in executeProviderRequest for all callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move attachLargeFileRemoteUrls out of the agent handler and into executeProviderRequest (right before uploadLargeFilesToProvider), so every entry point — including the internal providers passthrough — clears forged handles and mints/access-checks large-file URLs uniformly. The agent handler now only hydrates base64; its missing-file guard exempts large files (resolved downstream). --- .../executor/handlers/agent/agent-handler.ts | 13 +++-- apps/sim/providers/file-attachments.server.ts | 51 ++++++++----------- apps/sim/providers/index.ts | 6 ++- 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index ab8cd8abe22..798ba423ba4 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -39,8 +39,11 @@ import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { stringifyJSON } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' -import { INLINE_ATTACHMENT_THRESHOLD_BYTES, supportsFileAttachments } from '@/providers/attachments' -import { attachLargeFileRemoteUrls } from '@/providers/file-attachments.server' +import { + INLINE_ATTACHMENT_THRESHOLD_BYTES, + shouldUseLargeFilePath, + supportsFileAttachments, +} from '@/providers/attachments' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' import { filterSchemaForLLM, type ToolSchema } from '@/tools/params' @@ -764,9 +767,9 @@ export class AgentBlockHandler implements BlockHandler { maxBytes: INLINE_ATTACHMENT_THRESHOLD_BYTES, }) - await attachLargeFileRemoteUrls(hydratedFiles, providerId, { requestId, userId: ctx.userId }) - - const missingFile = hydratedFiles.find((file) => !file.base64 && !file.remoteUrl) + const missingFile = hydratedFiles.find( + (file) => !file.base64 && !shouldUseLargeFilePath(file, providerId) + ) if (missingFile) { throw new Error( `File "${missingFile.name}" could not be read for provider "${providerId}". The file may exceed the attachment size limit or may no longer be accessible.` diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 8b9c9ebf2a8..cffdcc06b24 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -25,9 +25,12 @@ const OPENAI_FILE_EXPIRY_SECONDS = 60 * 60 const GEMINI_POLL_INTERVAL_MS = 1000 const GEMINI_PROCESSING_TIMEOUT_MS = 5 * 60_000 -interface RemoteUrlContext { - requestId: string - userId?: string +function* iterateRequestFiles(messages: Message[] | undefined): Generator { + for (const message of messages ?? []) { + for (const file of message.files ?? []) { + yield file + } + } } /** @@ -37,17 +40,15 @@ interface RemoteUrlContext { * are read from storage at upload time). Requires cloud storage — otherwise large files * fall back to the (capped) base64 path. * - * The server-only handle fields are first cleared on every file for every provider - * (including inline) so a forged handle on untrusted input can never reach a builder. + * Runs for every request in {@link executeProviderRequest} (after the API key resolves), so + * the server-only handle fields are first cleared on every file for every provider — a forged + * handle on an untrusted request body can never survive to a builder or trigger a fetch. */ export async function attachLargeFileRemoteUrls( - files: UserFile[] | undefined, - providerId: ProviderId | string, - ctx: RemoteUrlContext + request: ProviderRequest, + providerId: ProviderId | string ): Promise { - if (!files?.length) return - - for (const file of files) { + for (const file of iterateRequestFiles(request.messages)) { file.providerFileId = undefined file.providerFileUri = undefined file.remoteUrl = undefined @@ -55,10 +56,12 @@ export async function attachLargeFileRemoteUrls( if (getProviderFileStrategy(providerId) === 'inline') return - for (const file of files) { + const requestId = request.workflowId ?? 'provider-request' + const maxBytes = getProviderAttachmentMaxBytes(providerId) + + for (const file of iterateRequestFiles(request.messages)) { if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue - const maxBytes = getProviderAttachmentMaxBytes(providerId) if (Number.isFinite(file.size) && file.size > maxBytes) { const sizeMB = (file.size / (1024 * 1024)).toFixed(2) const maxMB = (maxBytes / (1024 * 1024)).toFixed(0) @@ -69,19 +72,19 @@ export async function attachLargeFileRemoteUrls( if (!StorageService.hasCloudStorage()) { logger.warn( - `[${ctx.requestId}] "${file.name}" exceeds the inline limit for "${providerId}" but cloud storage is unavailable; it cannot be sent` + `[${requestId}] "${file.name}" exceeds the inline limit for "${providerId}" but cloud storage is unavailable; it cannot be sent` ) continue } - if (!ctx.userId) { + if (!request.userId) { throw new Error( `File "${file.name}" requires an authenticated user for provider "${providerId}"` ) } const context = (file.context as StorageContext) || inferContextFromKey(file.key) - const hasAccess = await verifyFileAccess(file.key, ctx.userId, undefined, context, false) + const hasAccess = await verifyFileAccess(file.key, request.userId, undefined, context, false) if (!hasAccess) { throw new Error(`File "${file.name}" is not accessible for provider "${providerId}"`) } @@ -96,12 +99,9 @@ export async function attachLargeFileRemoteUrls( /** * For `files-api` providers, uploads each large attachment (already carrying a signed - * `remoteUrl`) to the provider Files API and records the returned handle on the file. - * Runs after the request's API key is resolved so hosted and BYOK keys both work. - * - * Any `providerFileId`/`providerFileUri` present on input is cleared first — legitimate ids - * are only assigned by the upload below, so a forged id (which could otherwise reference an - * arbitrary file in a shared hosted provider account) can never reach a message builder. + * `remoteUrl` from {@link attachLargeFileRemoteUrls}) to the provider Files API and records + * the returned handle on the file. Runs after the request's API key is resolved so hosted + * and BYOK keys both work. */ export async function uploadLargeFilesToProvider( request: ProviderRequest, @@ -109,13 +109,6 @@ export async function uploadLargeFilesToProvider( ): Promise { if (getProviderFileStrategy(providerId) !== 'files-api') return - for (const message of request.messages ?? []) { - for (const file of message.files ?? []) { - file.providerFileId = undefined - file.providerFileUri = undefined - } - } - const groups = groupUploadableFiles(request.messages) if (groups.length === 0) return diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index d376c28a070..b75860a1b11 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -3,7 +3,10 @@ import { toError } from '@sim/utils/errors' import { getApiKeyWithBYOK } from '@/lib/api-key/byok' import { getCostMultiplier } from '@/lib/core/config/env-flags' import type { StreamingExecution } from '@/executor/types' -import { uploadLargeFilesToProvider } from '@/providers/file-attachments.server' +import { + attachLargeFileRemoteUrls, + uploadLargeFilesToProvider, +} from '@/providers/file-attachments.server' import { getProviderExecutor } from '@/providers/registry' import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' import { @@ -191,6 +194,7 @@ export async function executeProviderRequest( } } + await attachLargeFileRemoteUrls(sanitizedRequest, providerId) await uploadLargeFilesToProvider(sanitizedRequest, providerId) const response = await provider.executeRequest(sanitizedRequest) From 049ec393e6571769d1d285a534fa7c780a3428f6 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:34:56 -0700 Subject: [PATCH 13/16] fix(azure-openai): guard optional attachment dataUrl in inline image part PreparedProviderAttachment.dataUrl is now optional (large files carry a handle instead); azure-openai builds chat content inline and assigned it directly to a required url field, failing the production build's type check. --- apps/sim/providers/azure-openai/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 24d07184282..e9c7cfefb4b 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -113,7 +113,7 @@ async function executeChatCompletionsRequest( const parts: ChatCompletionContentPart[] = [] if (message.content) parts.push({ type: 'text', text: message.content }) for (const a of attachments) { - parts.push({ type: 'image_url', image_url: { url: a.dataUrl } }) + parts.push({ type: 'image_url', image_url: { url: a.remoteUrl ?? a.dataUrl ?? '' } }) } const { files: _files, ...rest } = message allMessages.push({ ...rest, content: parts } as ChatCompletionMessageParam) From 0e692f6a0f8f071e41a2300427fc2ed66ce9dc99 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:46:26 -0700 Subject: [PATCH 14/16] fix(providers): upload OpenAI files via multipart and fix Buffer Blob part The installed openai SDK (4.104) does not type expires_after on files.create, so upload via POST /v1/files directly with the documented expires_after[...] form fields (gives the file an auto-expiry). Also wrap the storage Buffer in a Uint8Array for the Blob, which the production build's stricter lib types require. These two type errors were masked locally because tsc was OOMing silently without the type-check script's --max-old-space-size flag. --- apps/sim/providers/file-attachments.server.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index cffdcc06b24..80f370727df 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -2,7 +2,6 @@ import { FileState, GoogleGenAI } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import OpenAI, { toFile } from 'openai' import type { StorageContext } from '@/lib/uploads' import { StorageService } from '@/lib/uploads' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' @@ -19,6 +18,7 @@ import type { Message, ProviderId, ProviderRequest } from '@/providers/types' const logger = createLogger('ProviderFileAttachments') +const OPENAI_FILES_ENDPOINT = 'https://api.openai.com/v1/files' const PRESIGNED_URL_EXPIRY_SECONDS = 60 * 60 /** OpenAI auto-deletes uploaded files after this window — see the "rely on provider expiry" lifecycle. */ const OPENAI_FILE_EXPIRY_SECONDS = 60 * 60 @@ -113,14 +113,13 @@ export async function uploadLargeFilesToProvider( if (groups.length === 0) return const maxBytes = getProviderAttachmentMaxBytes(providerId) - const openai = providerId === 'openai' ? new OpenAI({ apiKey: request.apiKey }) : null const ai = providerId === 'google' ? new GoogleGenAI({ apiKey: request.apiKey }) : null for (const group of groups) { const [representative] = group await assertFileAccessForUpload(representative, request.userId) - if (openai) { - await uploadOpenAIFile(representative, openai, maxBytes, request.abortSignal) + if (providerId === 'openai') { + await uploadOpenAIFile(representative, request.apiKey, maxBytes, request.abortSignal) } else if (ai) { await uploadGeminiFile(representative, ai, maxBytes, request.abortSignal) } @@ -178,27 +177,44 @@ function groupUploadableFiles(messages: Message[] | undefined): UserFile[][] { */ async function downloadFileForUpload(file: UserFile, maxBytes: number): Promise { const buffer = await downloadFileFromStorage(file, 'provider-file-upload', logger, { maxBytes }) - return new Blob([buffer], { type: file.type || inferAttachmentMimeType(file) }) + return new Blob([new Uint8Array(buffer)], { type: file.type || inferAttachmentMimeType(file) }) } +/** + * Uploads to `POST /v1/files` via multipart directly (not the SDK), because the installed + * `openai` SDK does not type `expires_after`; the bracketed form fields are the documented + * multipart encoding for the nested object and give the file an auto-expiry. + */ async function uploadOpenAIFile( file: UserFile, - client: OpenAI, + apiKey: string | undefined, maxBytes: number, signal?: AbortSignal ): Promise { const mimeType = inferAttachmentMimeType(file) const blob = await downloadFileForUpload(file, maxBytes) - const uploaded = await client.files.create( - { - file: await toFile(blob, file.name, { type: mimeType }), - purpose: mimeType.startsWith('image/') ? 'vision' : 'user_data', - expires_after: { anchor: 'created_at', seconds: OPENAI_FILE_EXPIRY_SECONDS }, - }, - { signal } - ) + const form = new FormData() + form.append('purpose', mimeType.startsWith('image/') ? 'vision' : 'user_data') + form.append('expires_after[anchor]', 'created_at') + form.append('expires_after[seconds]', String(OPENAI_FILE_EXPIRY_SECONDS)) + form.append('file', blob, file.name) + + const response = await fetch(OPENAI_FILES_ENDPOINT, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + body: form, + signal, + }) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`OpenAI file upload failed for "${file.name}" (${response.status}): ${detail}`) + } + const uploaded = (await response.json()) as { id?: string } + if (!uploaded.id) { + throw new Error(`OpenAI file upload for "${file.name}" returned no id`) + } file.providerFileId = uploaded.id logger.info(`Uploaded "${file.name}" to OpenAI Files API`, { fileId: uploaded.id }) } From 1919059d47bef2aa31588defad7b14b113da8679 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:52:17 -0700 Subject: [PATCH 15/16] fix(providers): forward userId from the providers API to executeProviderRequest Large-attachment prep now needs request.userId for presigned URLs and access checks; the authenticated providers proxy has auth.userId but wasn't passing it, so oversized attachments failed for logged-in callers. Forwarding it makes large files work there and keeps the access check (verifyFileAccess) intact. --- apps/sim/app/api/providers/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index bc5e344772b..49b90074943 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -202,6 +202,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { responseFormat, workflowId, workspaceId, + userId: auth.userId, stream, messages, environmentVariables, From 68849dd0d63e1d54e9f813bd8c7a069cdb5334b1 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 23:58:44 -0700 Subject: [PATCH 16/16] fix(providers): fail clearly when a large attachment has no cloud storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc claimed a base64 fallback that doesn't exist — above the inline cap there is no base64, so without cloud storage the file previously reached the builder and died with a generic read error. Throw a clear 'requires cloud file storage' error at the point of detection and correct the doc. --- apps/sim/providers/file-attachments.server.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 80f370727df..50b48a95465 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -37,8 +37,8 @@ function* iterateRequestFiles(messages: Message[] | undefined): Generator