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/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,
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..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
@@ -24,6 +24,8 @@ import {
useWorkspaceFiles,
workspaceFilesKeys,
} from '@/hooks/queries/workspace-files'
+import { getProviderAttachmentMaxBytes } from '@/providers/attachments'
+import { getProviderFromModel } from '@/providers/utils'
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
@@ -278,25 +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 maxSizeInBytes = maxSize * 1024 * 1024
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 ${maxSize}MB`
+ 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/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/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts
index b9c3a3eaad8..798ba423ba4 100644
--- a/apps/sim/executor/handlers/agent/agent-handler.ts
+++ b/apps/sim/executor/handlers/agent/agent-handler.ts
@@ -39,7 +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 { getProviderAttachmentMaxBytes, supportsFileAttachments } from '@/providers/attachments'
+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'
@@ -760,10 +764,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)
+ 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/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/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/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..6560c94e786 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,23 @@ 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: Array<'providerFileId' | 'providerFileUri' | 'remoteUrl'> = [
+ 'providerFileId',
+ 'providerFileUri',
+ 'remoteUrl',
+]
+
/**
* 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..e141a292505 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,133 @@ 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 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,
+ 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..6be9fb6b91f 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,25 @@ 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) {
+ 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 },
+ title: attachment.filename,
+ } satisfies Anthropic.Messages.DocumentBlockParam)
} else if (attachment.text) {
parts.push({
type: 'document',
@@ -431,7 +510,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 +531,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 +571,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 +599,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 +642,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/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/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)
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/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts
new file mode 100644
index 00000000000..50b48a95465
--- /dev/null
+++ b/apps/sim/providers/file-attachments.server.ts
@@ -0,0 +1,255 @@
+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 { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { verifyFileAccess } from '@/app/api/files/authorization'
+import type { UserFile } from '@/executor/types'
+import {
+ getProviderAttachmentMaxBytes,
+ 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
+
+function* iterateRequestFiles(messages: Message[] | undefined): Generator {
+ for (const message of messages ?? []) {
+ for (const file of message.files ?? []) {
+ yield file
+ }
+ }
+}
+
+/**
+ * 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 — a large file (already past
+ * the inline base64 cap) cannot be sent without it, so the request fails with a clear error.
+ *
+ * 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(
+ request: ProviderRequest,
+ providerId: ProviderId | string
+): Promise {
+ for (const file of iterateRequestFiles(request.messages)) {
+ file.providerFileId = undefined
+ file.providerFileUri = undefined
+ file.remoteUrl = undefined
+ }
+
+ if (getProviderFileStrategy(providerId) === 'inline') return
+
+ const requestId = request.workflowId ?? 'provider-request'
+ const maxBytes = getProviderAttachmentMaxBytes(providerId)
+
+ for (const file of iterateRequestFiles(request.messages)) {
+ if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue
+
+ 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(
+ `[${requestId}] "${file.name}" exceeds the inline limit for "${providerId}" but cloud storage is unavailable`
+ )
+ throw new Error(
+ `File "${file.name}" exceeds the inline attachment limit and requires cloud file storage, which is not configured`
+ )
+ }
+
+ 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, request.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` 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,
+ providerId: ProviderId | string
+): Promise {
+ if (getProviderFileStrategy(providerId) !== 'files-api') return
+
+ const groups = groupUploadableFiles(request.messages)
+ if (groups.length === 0) return
+
+ const maxBytes = getProviderAttachmentMaxBytes(providerId)
+ const ai = providerId === 'google' ? new GoogleGenAI({ apiKey: request.apiKey }) : null
+
+ for (const group of groups) {
+ const [representative] = group
+ await assertFileAccessForUpload(representative, request.userId)
+ if (providerId === 'openai') {
+ await uploadOpenAIFile(representative, request.apiKey, maxBytes, request.abortSignal)
+ } else if (ai) {
+ await uploadGeminiFile(representative, ai, maxBytes, request.abortSignal)
+ }
+ for (const file of group) {
+ file.providerFileId = representative.providerFileId
+ file.providerFileUri = representative.providerFileUri
+ }
+ }
+}
+
+/**
+ * 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.
+ */
+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()]
+}
+
+/**
+ * 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 downloadFileForUpload(file: UserFile, maxBytes: number): Promise {
+ const buffer = await downloadFileFromStorage(file, 'provider-file-upload', logger, { maxBytes })
+ 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,
+ apiKey: string | undefined,
+ maxBytes: number,
+ signal?: AbortSignal
+): Promise {
+ const mimeType = inferAttachmentMimeType(file)
+ const blob = await downloadFileForUpload(file, maxBytes)
+
+ 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,
+ maxBytes: number,
+ signal?: AbortSignal
+): Promise {
+ const mimeType = inferAttachmentMimeType(file)
+ 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) {
+ 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: uploadedName })
+ }
+
+ 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/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/index.ts b/apps/sim/providers/index.ts
index 26433940e33..b75860a1b11 100644
--- a/apps/sim/providers/index.ts
+++ b/apps/sim/providers/index.ts
@@ -3,6 +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 {
+ attachLargeFileRemoteUrls,
+ uploadLargeFilesToProvider,
+} from '@/providers/file-attachments.server'
import { getProviderExecutor } from '@/providers/registry'
import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types'
import {
@@ -190,6 +194,9 @@ export async function executeProviderRequest(
}
}
+ await attachLargeFileRemoteUrls(sanitizedRequest, providerId)
+ await uploadLargeFilesToProvider(sanitizedRequest, providerId)
+
const response = await provider.executeRequest(sanitizedRequest)
if (isStreamingExecution(response)) {
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/models.ts b/apps/sim/providers/models.ts
index fc6c172aac0..9381b7317ea 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: 50 * 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',
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'),
}))