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'), }))