Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/api/knowledge/search/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ vi.mock('@/lib/tokenization/estimators', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
calculateCost: vi.fn().mockReturnValue({
input: 0.00001042,
output: 0,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/providers/baseten/models/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
filterBlacklistedModels: mockFilterBlacklistedModels,
isProviderBlacklisted: mockIsProviderBlacklisted,
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
filterBlacklistedModels: mockFilterBlacklistedModels,
isProviderBlacklisted: mockIsProviderBlacklisted,
}))
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/providers/together/models/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
filterBlacklistedModels: mockFilterBlacklistedModels,
isProviderBlacklisted: mockIsProviderBlacklisted,
}))
Expand Down
1 change: 1 addition & 0 deletions apps/sim/blocks/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ vi.mock('@/providers/models', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
getProviderFromModel: vi.fn(() => 'openai'),
}))

Expand Down
1 change: 1 addition & 0 deletions apps/sim/ee/access-control/utils/permission-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ vi.mock('@/lib/permission-groups/types', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
getProviderFromModel: mockGetProviderFromModel,
}))

Expand Down
1 change: 1 addition & 0 deletions apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { executeTool } from '@/tools'
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
getProviderFromModel: vi.fn().mockReturnValue('mock-provider'),
transformBlockTool: vi.fn(),
getBaseModelProviders: vi.fn().mockReturnValue({ openai: {}, anthropic: {} }),
Expand Down
23 changes: 20 additions & 3 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { executeProviderRequest } from '@/providers'
import {
INLINE_ATTACHMENT_THRESHOLD_BYTES,
LARGE_FILE_PATH_THRESHOLD_BYTES,
shouldUseLargeFilePath,
supportsFileAttachments,
} from '@/providers/attachments'
import { canUseProviderLargeFilePath } from '@/providers/file-attachments.server'
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
Expand Down Expand Up @@ -946,6 +948,15 @@ export class AgentBlockHandler implements BlockHandler {
const requestId = ctx.executionId || ctx.workflowId || 'agent-files'
const nextMessages = [...messages]

/**
* Stop hydrating base64 early only where an upload can actually take over. Where it cannot —
* an inline-only provider, or any deployment without cloud storage — base64 stays the only
* delivery path, so it has to be hydrated all the way to the inline ceiling.
*/
const inlineMaxBytes = canUseProviderLargeFilePath(providerId)
? LARGE_FILE_PATH_THRESHOLD_BYTES
: INLINE_ATTACHMENT_THRESHOLD_BYTES

for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const message = messages[messageIndex]
if (!message.files?.length) {
Expand All @@ -963,15 +974,21 @@ export class AgentBlockHandler implements BlockHandler {
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
logger,
maxBytes: INLINE_ATTACHMENT_THRESHOLD_BYTES,
maxBytes: inlineMaxBytes,
})

const missingFile = hydratedFiles.find(
(file) => !file.base64 && !shouldUseLargeFilePath(file, providerId)
(file) =>
!file.base64 &&
!(canUseProviderLargeFilePath(providerId) && shouldUseLargeFilePath(file, providerId))
Comment thread
cursor[bot] marked this conversation as resolved.
)
if (missingFile) {
const inlineMB = (inlineMaxBytes / (1024 * 1024)).toFixed(0)
const oversized = Number.isFinite(missingFile.size) && missingFile.size > inlineMaxBytes
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.`
oversized
? `File "${missingFile.name}" (${(missingFile.size / (1024 * 1024)).toFixed(2)}MB) exceeds the ${inlineMB}MB inline attachment limit, and provider "${providerId}" has no large-file upload path for it.`
: `File "${missingFile.name}" could not be read for provider "${providerId}". The file may no longer be accessible.`
Comment thread
cursor[bot] marked this conversation as resolved.
)
}

Expand Down
1 change: 1 addition & 0 deletions apps/sim/executor/handlers/pi/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ vi.mock('@/lib/api-key/byok', () => ({
getBYOKKey: mockGetBYOKKey,
}))
vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
calculateCost: mockCalculateCost,
shouldBillModelUsage: mockShouldBill,
}))
Expand Down
1 change: 1 addition & 0 deletions apps/sim/executor/handlers/pi/pi-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ vi.mock('@/providers/pi-providers', () => ({
resolvePiModelId: mockResolvePiModelId,
}))
vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
getProviderFromModel: mockGetProviderFromModel,
}))
vi.mock('@/blocks/utils', () => ({
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/api-key/byok.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ vi.mock('@/providers/models', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
PROVIDER_PLACEHOLDER_KEY: 'placeholder',
}))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ vi.mock('@/lib/workflows/skills/operations', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
getHostedModels: mockGetHostedModels,
}))

Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/model-router/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
getProviderFromModel: mockGetProviderFromModel,
}))

Expand Down
34 changes: 34 additions & 0 deletions apps/sim/lib/uploads/utils/user-file-base64.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,40 @@ describe('hydrateUserFilesWithBase64', () => {
expect(mockRedis.eval).toHaveBeenCalledOnce()
})

/**
* Reproduces the agent-attachment failure: a file under the inline limit whose base64 exceeds
* the 8 MiB single-Redis-write cap. The bytes are already read by the time the cache is
* written, so a refused cache write must degrade to "not cached", not fail the execution.
*/
it('still returns base64 when the value is too large to cache', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
const buffer = Buffer.alloc(9 * 1024 * 1024, 0x61)
mockDownloadFile.mockResolvedValueOnce(buffer)
const file: UserFile = {
id: 'file-1',
name: 'data_10mb.csv',
key: 'execution/workspace/workflow/exec-1/data_10mb.csv',
url: 'https://example.com/data_10mb.csv',
size: buffer.length,
type: 'text/csv',
context: 'execution',
}

const hydrated = await hydrateUserFilesWithBase64(
{ file },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'exec-1',
userId: 'user-1',
maxBytes: 10 * 1024 * 1024,
}
)

expect(hydrated.file.base64).toBe(buffer.toString('base64'))
expect(mockRedis.eval).not.toHaveBeenCalled()
})

it('releases indexed budget entries even when cache keys already expired', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
mockRedis.hgetall.mockResolvedValueOnce({
Expand Down
63 changes: 43 additions & 20 deletions apps/sim/lib/uploads/utils/user-file-base64.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@ import {
getExecutionRedisBudgetKeys,
getExecutionRedisBudgetLimits,
} from '@/lib/execution/redis-budget.server'
import {
ExecutionResourceLimitError,
isExecutionResourceLimitError,
} from '@/lib/execution/resource-errors'
import { ExecutionResourceLimitError } from '@/lib/execution/resource-errors'
import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils'
import type { UserFile } from '@/executor/types'

Expand Down Expand Up @@ -196,6 +193,24 @@ class InMemoryBase64Cache implements Base64Cache {
}
}

/**
* The base64 cache only saves a repeat read from storage — the bytes it would have stored are
* already in hand by the time it is written. Exceeding a Redis budget therefore means "do not
* cache", never "fail the run": throwing here turned an oversized attachment into an opaque
* "Execution memory limit exceeded" on a request that had already read the file successfully.
*/
function logSkippedCacheWrite(
logger: Logger,
requestId: string | undefined,
file: UserFile,
error: ExecutionResourceLimitError
): void {
logger.warn(
`[${requestId ?? 'unknown'}] Skipping base64 cache write for ${file.name}: ${error.message}`,
{ resource: error.resource, attemptedBytes: error.attemptedBytes }
)
}

function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Base64Cache {
const redis = getRedisClient()
const { executionId } = options
Expand Down Expand Up @@ -228,11 +243,17 @@ function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Bas

const limits = getExecutionRedisBudgetLimits()
if (valueBytes > limits.maxSingleWriteBytes) {
throw new ExecutionResourceLimitError({
resource: 'redis_key_bytes',
attemptedBytes: valueBytes,
limitBytes: limits.maxSingleWriteBytes,
})
logSkippedCacheWrite(
logger,
options.requestId,
file,
new ExecutionResourceLimitError({
resource: 'redis_key_bytes',
attemptedBytes: valueBytes,
limitBytes: limits.maxSingleWriteBytes,
})
)
return
}
const cacheTtlSeconds = Math.max(ttlSeconds, limits.ttlSeconds)
const budgetReservation: ExecutionRedisBudgetReservation = {
Expand Down Expand Up @@ -261,19 +282,21 @@ function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Bas
)) as [number, string, number | string | null]
const [allowed, resource, current] = result
if (allowed !== 1) {
throw new ExecutionResourceLimitError({
resource:
resource === 'user_redis_bytes' ? 'user_redis_bytes' : 'execution_redis_bytes',
attemptedBytes: valueBytes,
currentBytes: Number(current ?? 0),
limitBytes:
resource === 'user_redis_bytes' ? limits.maxUserBytes : limits.maxExecutionBytes,
})
logSkippedCacheWrite(
logger,
options.requestId,
file,
new ExecutionResourceLimitError({
resource:
resource === 'user_redis_bytes' ? 'user_redis_bytes' : 'execution_redis_bytes',
attemptedBytes: valueBytes,
currentBytes: Number(current ?? 0),
limitBytes:
resource === 'user_redis_bytes' ? limits.maxUserBytes : limits.maxExecutionBytes,
})
)
}
} catch (error) {
if (isExecutionResourceLimitError(error)) {
throw error
}
logger.warn(`[${options.requestId}] Redis set failed, skipping cache`, error)
}
},
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"dependencies": {
"@1password/sdk": "0.3.1",
"@a2a-js/sdk": "1.0.0-alpha.0",
"@anthropic-ai/sdk": "0.114.0",
"@anthropic-ai/sdk": "0.115.0",
"@aws-sdk/client-appconfig": "3.1032.0",
"@aws-sdk/client-appconfigdata": "3.1032.0",
"@aws-sdk/client-athena": "3.1032.0",
Expand Down Expand Up @@ -73,7 +73,7 @@
"@earendil-works/pi-coding-agent": "0.80.10",
"@floating-ui/dom": "1.7.6",
"@google-cloud/storage": "7.21.0",
"@google/genai": "1.34.0",
"@google/genai": "2.13.0",
"@hookform/resolvers": "5.2.2",
"@linear/sdk": "40.0.0",
"@marsidev/react-turnstile": "1.4.2",
Expand Down Expand Up @@ -200,7 +200,7 @@
"nodemailer": "9.0.1",
"nuqs": "2.8.9",
"officeparser": "^5.2.0",
"openai": "^4.91.1",
"openai": "7.0.0",
"pdf-lib": "1.17.1",
"pdfjs-dist": "5.4.296",
"postgres": "^3.4.5",
Expand Down
1 change: 1 addition & 0 deletions apps/sim/providers/anthropic/streaming-tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock('@/tools', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null,
prepareToolExecution: mockPrepareToolExecution,
calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }),
sumToolCosts: () => 0,
Expand Down
33 changes: 30 additions & 3 deletions apps/sim/providers/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
import type { UserFile } from '@/executor/types'
import {
buildAnthropicMessageContent,
Expand All @@ -15,6 +16,7 @@ import {
getProviderFileStrategy,
INLINE_ATTACHMENT_THRESHOLD_BYTES,
inferAttachmentMimeType,
LARGE_FILE_PATH_THRESHOLD_BYTES,
prepareProviderAttachments,
shouldUseLargeFilePath,
} from '@/providers/attachments'
Expand Down Expand Up @@ -286,6 +288,30 @@ describe('provider attachments', () => {
})

describe('provider large-file capability', () => {
/**
* Guards the regression where every 6-10 MB attachment died with "Execution memory limit
* exceeded": past this size the base64 copy no longer fits the payload store, so an upload
* has to take over wherever one is reachable.
*/
it('starts preferring an upload before base64 outgrows the payload store', () => {
const encodedBytes = Math.ceil(LARGE_FILE_PATH_THRESHOLD_BYTES / 3) * 4
expect(encodedBytes).toBeLessThanOrEqual(LARGE_VALUE_THRESHOLD_BYTES)
expect(LARGE_FILE_PATH_THRESHOLD_BYTES).toBeLessThan(INLINE_ATTACHMENT_THRESHOLD_BYTES)
})

/**
* A `remote-url` provider only fetches images and PDFs, so it must not take over from base64
* early — text documents in the 6-10 MB band inline fine today and would start failing.
*/
it('crosses over to an upload at different sizes for files-api and remote-url', () => {
const midBand = { size: LARGE_FILE_PATH_THRESHOLD_BYTES + 1, type: 'text/plain' }
expect(shouldUseLargeFilePath(midBand, 'openai')).toBe(true)
expect(shouldUseLargeFilePath(midBand, 'anthropic')).toBe(false)

const aboveInline = { size: INLINE_ATTACHMENT_THRESHOLD_BYTES + 1, type: 'application/pdf' }
expect(shouldUseLargeFilePath(aboveInline, 'anthropic')).toBe(true)
})

it('reports per-provider strategy and ceiling, defaulting others to inline', () => {
expect(getProviderFileStrategy('openai')).toBe('files-api')
expect(getProviderFileStrategy('google')).toBe('files-api')
Expand All @@ -298,13 +324,14 @@ describe('provider large-file capability', () => {
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)
/** Bedrock Converse caps a document at 4.5MB — below the inline cap, so it needs its own entry. */
expect(getProviderAttachmentMaxBytes('bedrock')).toBe(4_500_000)
})

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 }
const large = { ...imageFile, size: LARGE_FILE_PATH_THRESHOLD_BYTES + 1 }
expect(shouldUseLargeFilePath(small, 'openai')).toBe(false)
expect(shouldUseLargeFilePath(large, 'openai')).toBe(true)
expect(shouldUseLargeFilePath(large, 'bedrock')).toBe(false)
Expand All @@ -313,7 +340,7 @@ describe('provider large-file capability', () => {
it('does not expose generated source through a remote-url large-file path', () => {
const generated = {
...pdfFile,
size: INLINE_ATTACHMENT_THRESHOLD_BYTES + 1,
size: LARGE_FILE_PATH_THRESHOLD_BYTES + 1,
type: 'text/x-python-pdf',
}
expect(shouldUseLargeFilePath(generated, 'openai')).toBe(true)
Expand Down
Loading
Loading