Skip to content

Commit d28a752

Browse files
committed
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.
1 parent b7d30c8 commit d28a752

12 files changed

Lines changed: 546 additions & 50 deletions

File tree

apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { ModelTimelineChart } from '@/app/(landing)/models/components/model-timeline-chart'
1414
import {
1515
buildProviderFaqs,
16+
formatFileSize,
1617
formatPrice,
1718
formatTokenCount,
1819
getProviderBySlug,
@@ -204,9 +205,16 @@ export default async function ProviderModelsPage({
204205
{provider.name} models
205206
</h1>
206207
</div>
207-
<span className='shrink-0 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
208-
{provider.modelCount} models
209-
</span>
208+
<div className='flex shrink-0 flex-col items-end gap-1'>
209+
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
210+
{provider.modelCount} models
211+
</span>
212+
{provider.maxFileAttachmentBytes ? (
213+
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
214+
{formatFileSize(provider.maxFileAttachmentBytes)} file uploads
215+
</span>
216+
) : null}
217+
</div>
210218
</div>
211219
</div>
212220

apps/sim/app/(landing)/models/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ export interface CatalogProvider {
127127
color?: string
128128
isReseller: boolean
129129
contextInformationAvailable: boolean
130+
/** Max agent-block file attachment size in bytes when the provider exceeds the default. */
131+
maxFileAttachmentBytes: number | null
130132
providerCapabilityTags: string[]
131133
modelCount: number
132134
models: CatalogModel[]
@@ -150,6 +152,18 @@ export function formatTokenCount(value?: number | null): string {
150152
return value.toLocaleString('en-US')
151153
}
152154

155+
export function formatFileSize(bytes?: number | null): string {
156+
if (bytes == null) {
157+
return 'Unknown'
158+
}
159+
160+
const gb = bytes / (1024 * 1024 * 1024)
161+
if (gb >= 1) {
162+
return `${trimTrailingZeros(gb.toFixed(1))}GB`
163+
}
164+
return `${Math.round(bytes / (1024 * 1024))}MB`
165+
}
166+
153167
export function formatPrice(price?: number | null): string {
154168
if (price === undefined || price === null) {
155169
return 'N/A'
@@ -507,6 +521,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
507521
color: provider.color,
508522
isReseller: provider.isReseller ?? false,
509523
contextInformationAvailable: provider.contextInformationAvailable !== false,
524+
maxFileAttachmentBytes: provider.fileAttachment?.maxBytes ?? null,
510525
providerCapabilityTags,
511526
modelCount: models.length,
512527
models,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
useWorkspaceFiles,
2525
workspaceFilesKeys,
2626
} from '@/hooks/queries/workspace-files'
27+
import { getProviderAttachmentMaxBytes } from '@/providers/attachments'
28+
import { getProviderFromModel } from '@/providers/models'
2729
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2830
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
2931

@@ -167,6 +169,7 @@ export function FileUpload({
167169
}: FileUploadProps) {
168170
const activeSearchTarget = useActiveSearchTarget()
169171
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
172+
const [modelValue] = useSubBlockValue(blockId, 'model')
170173
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
171174
const [uploadProgress, setUploadProgress] = useState(0)
172175
const [uploadError, setUploadError] = useState<string | null>(null)
@@ -191,6 +194,17 @@ export function FileUpload({
191194

192195
const value = isPreview ? previewValue : storeValue
193196

197+
const maxSizeInBytes = useMemo(() => {
198+
const fallback = maxSize * 1024 * 1024
199+
if (typeof modelValue !== 'string' || !modelValue) return fallback
200+
try {
201+
return Math.max(fallback, getProviderAttachmentMaxBytes(getProviderFromModel(modelValue)))
202+
} catch {
203+
return fallback
204+
}
205+
}, [modelValue, maxSize])
206+
const maxSizeLabel = `${Math.round(maxSizeInBytes / (1024 * 1024))}MB`
207+
194208
/**
195209
* Checks if a file's MIME type matches the accepted types
196210
* Supports exact matches, wildcard patterns (e.g., 'image/*'), and '*' for all types
@@ -281,15 +295,14 @@ export function FileUpload({
281295
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
282296
const existingTotalSize = existingFiles.reduce((sum, file) => sum + file.size, 0)
283297

284-
const maxSizeInBytes = maxSize * 1024 * 1024
285298
const validFiles: File[] = []
286299
let totalNewSize = 0
287300
let sizeExceededFile: string | null = null
288301

289302
for (let i = 0; i < files.length; i++) {
290303
const file = files[i]
291304
if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) {
292-
const errorMessage = `Adding ${file.name} would exceed the maximum size limit of ${maxSize}MB`
305+
const errorMessage = `Adding ${file.name} would exceed the maximum size limit of ${maxSizeLabel}`
293306
logger.error(errorMessage, activeWorkflowId)
294307
if (!sizeExceededFile) {
295308
sizeExceededFile = errorMessage

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
3939
import { stringifyJSON } from '@/executor/utils/json'
4040
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
4141
import { executeProviderRequest } from '@/providers'
42-
import { getProviderAttachmentMaxBytes, supportsFileAttachments } from '@/providers/attachments'
42+
import { INLINE_ATTACHMENT_THRESHOLD_BYTES, supportsFileAttachments } from '@/providers/attachments'
43+
import { attachLargeFileRemoteUrls } from '@/providers/file-attachments.server'
4344
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
4445
import type { SerializedBlock } from '@/serializer/types'
4546
import { filterSchemaForLLM, type ToolSchema } from '@/tools/params'
@@ -760,10 +761,12 @@ export class AgentBlockHandler implements BlockHandler {
760761
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
761762
userId: ctx.userId,
762763
logger,
763-
maxBytes: getProviderAttachmentMaxBytes(providerId),
764+
maxBytes: INLINE_ATTACHMENT_THRESHOLD_BYTES,
764765
})
765766

766-
const missingFile = hydratedFiles.find((file) => !file.base64)
767+
await attachLargeFileRemoteUrls(hydratedFiles, providerId, { requestId, userId: ctx.userId })
768+
769+
const missingFile = hydratedFiles.find((file) => !file.base64 && !file.remoteUrl)
767770
if (missingFile) {
768771
throw new Error(
769772
`File "${missingFile.name}" could not be read for provider "${providerId}". The file may exceed the attachment size limit or may no longer be accessible.`

apps/sim/executor/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export interface UserFile {
2020
key: string
2121
context?: string
2222
base64?: string
23+
/** Provider Files API handle (OpenAI/Anthropic `file_...` id) set when a large file is uploaded instead of inlined as base64. */
24+
providerFileId?: string
25+
/** Provider File API uri (Gemini `fileUri`) set when a large file is uploaded instead of inlined as base64. */
26+
providerFileUri?: string
27+
/** Short-lived signed HTTPS URL passed to providers that fetch attachments by remote URL instead of inlining base64. */
28+
remoteUrl?: string
2329
}
2430

2531
export interface ParallelPauseScope {

apps/sim/lib/uploads/utils/file-utils.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { createLogger } from '@sim/logger'
45
import { describe, expect, it } from 'vitest'
5-
import { isAbortError, isInternalFileUrl, isNetworkError } from '@/lib/uploads/utils/file-utils'
6+
import {
7+
isAbortError,
8+
isInternalFileUrl,
9+
isNetworkError,
10+
processSingleFileToUserFile,
11+
} from '@/lib/uploads/utils/file-utils'
12+
13+
const logger = createLogger('FileUtilsTest')
614

715
describe('isInternalFileUrl', () => {
816
it('classifies relative serve paths as internal', () => {
@@ -72,3 +80,28 @@ describe('isNetworkError', () => {
7280
expect(isNetworkError(null)).toBe(false)
7381
})
7482
})
83+
84+
describe('processSingleFileToUserFile', () => {
85+
it('strips server-only provider file handles from untrusted input', () => {
86+
const result = processSingleFileToUserFile(
87+
{
88+
id: 'file-1',
89+
name: 'doc.pdf',
90+
url: '/api/files/serve/workspace%2Fws-1%2Fdoc.pdf?context=workspace',
91+
size: 1024,
92+
type: 'application/pdf',
93+
key: 'workspace/ws-1/doc.pdf',
94+
providerFileId: 'file-injected',
95+
providerFileUri: 'https://injected/uri',
96+
remoteUrl: 'http://169.254.169.254/latest/meta-data',
97+
} as never,
98+
'req-1',
99+
logger
100+
)
101+
102+
expect(result.providerFileId).toBeUndefined()
103+
expect(result.providerFileUri).toBeUndefined()
104+
expect(result.remoteUrl).toBeUndefined()
105+
expect(result.key).toBe('workspace/ws-1/doc.pdf')
106+
})
107+
})

apps/sim/lib/uploads/utils/file-utils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Logger } from '@sim/logger'
2+
import { omit } from '@sim/utils/object'
23
import type { StorageContext } from '@/lib/uploads'
34
import { ACCEPTED_FILE_TYPES, SUPPORTED_DOCUMENT_EXTENSIONS } from '@/lib/uploads/utils/validation'
45
import { isUuid } from '@/executor/constants'
@@ -696,13 +697,19 @@ function resolveInternalFileUrl(file: RawFileInput): string {
696697
return ''
697698
}
698699

700+
/**
701+
* Provider large-file handles are populated by the server pipeline and must never be
702+
* accepted from untrusted file input (they drive server-side fetch/upload).
703+
*/
704+
const PROVIDER_FILE_HANDLE_FIELDS = ['providerFileId', 'providerFileUri', 'remoteUrl'] as const
705+
699706
/**
700707
* Core conversion logic from RawFileInput to UserFile
701708
*/
702709
function convertToUserFile(file: RawFileInput, requestId: string, logger: Logger): UserFile | null {
703710
if (isCompleteUserFile(file)) {
704711
return {
705-
...file,
712+
...omit(file, PROVIDER_FILE_HANDLE_FIELDS),
706713
url: resolveInternalFileUrl(file) || file.url,
707714
}
708715
}

apps/sim/providers/attachments.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ import {
77
buildAnthropicMessageContent,
88
buildBedrockMessageContent,
99
buildGeminiMessageParts,
10+
buildOpenAICompatibleChatContent,
1011
buildOpenAIMessageContent,
1112
buildOpenRouterMessageContent,
1213
formatMessagesForProvider,
14+
getProviderAttachmentMaxBytes,
15+
getProviderFileStrategy,
16+
INLINE_ATTACHMENT_THRESHOLD_BYTES,
1317
inferAttachmentMimeType,
1418
prepareProviderAttachments,
19+
shouldUseLargeFilePath,
1520
} from '@/providers/attachments'
1621

1722
const imageFile: UserFile = {
@@ -270,3 +275,99 @@ describe('provider attachments', () => {
270275
).toThrow('not supported')
271276
})
272277
})
278+
279+
describe('provider large-file capability', () => {
280+
it('reports per-provider strategy and ceiling, defaulting others to inline', () => {
281+
expect(getProviderFileStrategy('openai')).toBe('files-api')
282+
expect(getProviderFileStrategy('google')).toBe('files-api')
283+
expect(getProviderFileStrategy('anthropic')).toBe('remote-url')
284+
expect(getProviderFileStrategy('groq')).toBe('remote-url')
285+
expect(getProviderFileStrategy('bedrock')).toBe('inline')
286+
expect(getProviderFileStrategy('azure-openai')).toBe('inline')
287+
expect(getProviderFileStrategy('vertex')).toBe('inline')
288+
289+
expect(getProviderAttachmentMaxBytes('openai')).toBeGreaterThan(
290+
INLINE_ATTACHMENT_THRESHOLD_BYTES
291+
)
292+
expect(getProviderAttachmentMaxBytes('bedrock')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES)
293+
expect(getProviderAttachmentMaxBytes('azure-openai')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES)
294+
})
295+
296+
it('routes only oversized files on capable providers to the large-file path', () => {
297+
const small = { ...imageFile, size: 1024 }
298+
const large = { ...imageFile, size: INLINE_ATTACHMENT_THRESHOLD_BYTES + 1 }
299+
expect(shouldUseLargeFilePath(small, 'openai')).toBe(false)
300+
expect(shouldUseLargeFilePath(large, 'openai')).toBe(true)
301+
expect(shouldUseLargeFilePath(large, 'bedrock')).toBe(false)
302+
})
303+
304+
it('references uploaded OpenAI files by file_id instead of inlining base64', () => {
305+
const content = buildOpenAIMessageContent(
306+
'Analyze',
307+
[
308+
{ ...imageFile, base64: undefined, providerFileId: 'file-img' },
309+
{ ...pdfFile, base64: undefined, providerFileId: 'file-doc' },
310+
],
311+
'openai'
312+
)
313+
expect(content).toEqual([
314+
{ type: 'input_text', text: 'Analyze' },
315+
{ type: 'input_image', file_id: 'file-img', detail: 'auto' },
316+
{ type: 'input_file', file_id: 'file-doc' },
317+
])
318+
})
319+
320+
it('references large Anthropic files via url content-block sources', () => {
321+
const content = buildAnthropicMessageContent(
322+
'Analyze',
323+
[
324+
{ ...imageFile, base64: undefined, remoteUrl: 'https://signed/img.png' },
325+
{ ...pdfFile, base64: undefined, remoteUrl: 'https://signed/doc.pdf' },
326+
],
327+
'anthropic'
328+
)
329+
expect(content).toEqual([
330+
{ type: 'text', text: 'Analyze' },
331+
{ type: 'image', source: { type: 'url', url: 'https://signed/img.png' } },
332+
{
333+
type: 'document',
334+
source: { type: 'url', url: 'https://signed/doc.pdf' },
335+
title: 'example.pdf',
336+
},
337+
])
338+
})
339+
340+
it('references uploaded Gemini files via fileData uri', () => {
341+
const parts = buildGeminiMessageParts(
342+
'Analyze',
343+
[{ ...imageFile, base64: undefined, providerFileUri: 'https://files/abc' }],
344+
'google'
345+
)
346+
expect(parts).toEqual([
347+
{ text: 'Analyze' },
348+
{ fileData: { fileUri: 'https://files/abc', mimeType: 'image/png' } },
349+
])
350+
})
351+
352+
it('passes a remote url to OpenAI-compatible providers instead of a data url', () => {
353+
const content = buildOpenAICompatibleChatContent(
354+
'Analyze',
355+
[{ ...imageFile, base64: undefined, remoteUrl: 'https://signed/img.png' }],
356+
'groq'
357+
)
358+
expect(content).toEqual([
359+
{ type: 'text', text: 'Analyze' },
360+
{ type: 'image_url', image_url: { url: 'https://signed/img.png' } },
361+
])
362+
})
363+
364+
it('rejects files above the provider ceiling', () => {
365+
const huge = {
366+
...imageFile,
367+
size: getProviderAttachmentMaxBytes('openai') + 1,
368+
base64: undefined,
369+
providerFileId: 'file-img',
370+
}
371+
expect(() => buildOpenAIMessageContent('Analyze', [huge], 'openai')).toThrow('exceeds the')
372+
})
373+
})

0 commit comments

Comments
 (0)