Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 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,11 @@ vi.mock('@/lib/tokenization/estimators', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: unknown) =>
typeof toolCall === 'object' &&
toolCall !== null &&
'function' in toolCall &&
(toolCall as { function?: unknown }).function != null,
calculateCost: vi.fn().mockReturnValue({
input: 0.00001042,
output: 0,
Expand Down
5 changes: 5 additions & 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,11 @@ const {
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: unknown) =>
typeof toolCall === 'object' &&
toolCall !== null &&
'function' in toolCall &&
(toolCall as { function?: unknown }).function != null,
filterBlacklistedModels: mockFilterBlacklistedModels,
isProviderBlacklisted: mockIsProviderBlacklisted,
}))
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/providers/ollama-cloud/models/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const {
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: unknown) =>
typeof toolCall === 'object' &&
toolCall !== null &&
'function' in toolCall &&
(toolCall as { function?: unknown }).function != null,
filterBlacklistedModels: mockFilterBlacklistedModels,
isProviderBlacklisted: mockIsProviderBlacklisted,
}))
Expand Down
5 changes: 5 additions & 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,11 @@ const {
}))

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

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

Expand Down
5 changes: 5 additions & 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,11 @@ vi.mock('@/lib/permission-groups/types', () => ({
}))

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

Expand Down
5 changes: 5 additions & 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,11 @@ import { executeTool } from '@/tools'
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: unknown) =>
typeof toolCall === 'object' &&
toolCall !== null &&
'function' in toolCall &&
(toolCall as { function?: unknown }).function != null,
getProviderFromModel: vi.fn().mockReturnValue('mock-provider'),
transformBlockTool: vi.fn(),
getBaseModelProviders: vi.fn().mockReturnValue({ openai: {}, anthropic: {} }),
Expand Down
37 changes: 33 additions & 4 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ import { stringifyJSON } from '@/executor/utils/json'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { executeProviderRequest } from '@/providers'
import {
INLINE_ATTACHMENT_THRESHOLD_BYTES,
formatAttachmentSizes,
getProviderFileStrategy,
shouldUseLargeFilePath,
supportsFileAttachments,
} from '@/providers/attachments'
import {
canUseProviderLargeFilePath,
getInlineHydrationMaxBytes,
} 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 +951,8 @@ export class AgentBlockHandler implements BlockHandler {
const requestId = ctx.executionId || ctx.workflowId || 'agent-files'
const nextMessages = [...messages]

const inlineMaxBytes = getInlineHydrationMaxBytes(providerId)

for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const message = messages[messageIndex]
if (!message.files?.length) {
Expand All @@ -963,15 +970,37 @@ 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 { size: sizeMB, limit: inlineMB } = formatAttachmentSizes(
missingFile.size,
inlineMaxBytes
)
const oversized = Number.isFinite(missingFile.size) && missingFile.size > inlineMaxBytes
/**
* Ordered by how general the cause is. A provider with no upload path at all cannot be
* helped by changing the file, and a deployment with no object storage cannot reach any
* upload path whatever the file is — so both outrank the format-specific case. Leading
* with the generated-document arm blamed the document on providers that have no upload
* path for anything, and on hosts whose only real problem was unconfigured storage.
*/
const reason =
getProviderFileStrategy(providerId) === 'inline'
? `provider "${providerId}" has no large-file upload path`
: !canUseProviderLargeFilePath(providerId)
? 'this deployment has no cloud file storage for the large-file upload path'
: `a generated document cannot use the large-file path for provider "${providerId}", because a signed URL points at the generation source rather than the rendered file`
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}" (${sizeMB}MB) exceeds the ${inlineMB}MB inline attachment limit, and ${reason}.`
: `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
5 changes: 5 additions & 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,11 @@ vi.mock('@/lib/api-key/byok', () => ({
getBYOKKey: mockGetBYOKKey,
}))
vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: unknown) =>
typeof toolCall === 'object' &&
toolCall !== null &&
'function' in toolCall &&
(toolCall as { function?: unknown }).function != null,
calculateCost: mockCalculateCost,
shouldBillModelUsage: mockShouldBill,
}))
Expand Down
5 changes: 5 additions & 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,11 @@ vi.mock('@/providers/pi-providers', () => ({
resolvePiModelId: mockResolvePiModelId,
}))
vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: unknown) =>
typeof toolCall === 'object' &&
toolCall !== null &&
'function' in toolCall &&
(toolCall as { function?: unknown }).function != null,
getProviderFromModel: mockGetProviderFromModel,
}))
vi.mock('@/blocks/utils', () => ({
Expand Down
5 changes: 5 additions & 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,11 @@ vi.mock('@/providers/models', () => ({
}))

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

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

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

Expand Down
5 changes: 5 additions & 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,11 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: unknown) =>
typeof toolCall === 'object' &&
toolCall !== null &&
'function' in toolCall &&
(toolCall as { function?: unknown }).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
5 changes: 5 additions & 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,11 @@ vi.mock('@/tools', () => ({
}))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: (toolCall: unknown) =>
typeof toolCall === 'object' &&
toolCall !== null &&
'function' in toolCall &&
(toolCall as { function?: unknown }).function != null,
prepareToolExecution: mockPrepareToolExecution,
calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }),
sumToolCosts: () => 0,
Expand Down
Loading
Loading