Skip to content

Commit d05289c

Browse files
authored
fix(providers): route 6-10MB attachments to the provider large-file path (#6232)
* fix(providers): route 6-10MB attachments to the provider large-file path The inline base64 cap was 10 MB of raw bytes, but the execution payload store refuses a single value above 8 MiB and base64 inflates by 4/3. Every raw file over 6 MiB therefore produced a base64 string the store rejected — and the rejection came from the base64 *cache* write, which threw and failed the run with "Execution memory limit exceeded" even though the bytes had already been read successfully. Because shouldUseLargeFilePath only fires above the inline cap, 6-10 MB attachments had no path at all on any provider: they never reached the OpenAI or Gemini Files API upload they were supposed to take. Derive the cap from the payload-store ceiling instead of hardcoding it, and degrade a refused cache write to "not cached" rather than failing a request whose bytes are in hand. Every other size guard in the chain compares raw bytes against maxBytes; only the Redis write sees the encoded size, which is why this went unnoticed — and why it failed only where Redis is configured. Also correct the provider ceilings against the vendors' current documentation: - openai: 50 MiB -> 50,000,000. The gate is `size > maxBytes`, so 50 MiB admitted 52,428,800 bytes; the docs say each file must be *under* 50 MB. - bedrock: had no entry and inherited the inline cap, which is above what Converse accepts (3.75 MB per image, 4.5 MB per document). - groq: 20 MiB -> 20,000,000, and modelled as the request cap the docs actually describe rather than a per-file MiB ceiling. - fireworks: had no entry; its 10 MB budget is on the base64 total, so the raw-byte equivalent is 7.5 MB. Add perRequestMaxBytes for the combined ceilings, enforced before any upload spend, and cover the OpenAI upload path end to end — it had no test at all. * chore(deps): upgrade @google/genai to 2.13.0 and @anthropic-ai/sdk to 0.115.0 @google/genai 2.x reworks the Interactions API, which the Gemini deep-research provider is built on. Migrate it: - `Interaction.outputs` (a flat content array) is now `steps`, a discriminated timeline; the report text lives in the `model_output` steps' text content, alongside thought and tool steps we skip. - `Usage.total_reasoning_tokens` is now `total_thought_tokens`. The old code already fell back to that name through a cast, so this just makes the field the SDK actually returns the typed one. - SSE events renamed: `content.delta` -> `step.delta`, `interaction.start` -> `interaction.created`, `interaction.complete` -> `interaction.completed`. The new event types are discriminated, so the payload casts are gone. Both `interactions.create` calls also stop annotating their params with `Interactions.CreateAgentInteractionParams{,Non}Streaming`. In 2.13.0 those namespace aliases resolve to `CreateAgentInteraction`, whose `stream` is a plain `boolean` rather than a literal — annotating with them erases the discriminant and the call resolves to the union-returning overload, so the result is typed as `Interaction | Stream` at every use. An inline `stream: true as const` keeps the correct overload. Neither upgrade required a `minimum-release-age` waiver: 2.15.0 and 0.115.0 were checked and 2.13.0 is the newest genai release clearing the 7-day window. * chore(deps): upgrade openai to 7.0.0 v5 is the only major with real breaking changes for us; v6 widened a Responses output type and v7 only raised the Node floor to 22, which apps/sim already requires. Three things needed fixing: `ChatCompletionMessageToolCall` became a union of function and custom tool calls, and the custom variant has no `function` field — 43 unguarded `.function` accesses across the OpenAI-compatible providers. Narrow once at each `message.tool_calls` read through a shared `isFunctionToolCall` guard rather than casting at every use. That guard deliberately tests for the `function` payload instead of `type === 'function'`. Many OpenAI-compatible vendors omit `type` on tool calls entirely — our own fixtures do — so discriminating on it type-checks perfectly and then silently drops every tool call those providers return. `ChatCompletionCreateParams.verbosity` narrowed from `string` to a literal union, and the Responses API's output and input item unions now diverge on members Sim never emits (computer-use call outputs, whose `status` admits `failed`, and the `AdditionalTools` escape hatch). Echoing output back as input is what a tool loop is supposed to do, so that conversion is asserted once in convertResponseOutputToInputItems and the streaming loop now routes through it instead of pushing raw output items. The hand-rolled multipart upload in file-attachments.server.ts can now be replaced with the SDK's typed `expires_after` — left for a follow-up so this commit stays a pure upgrade. * fix(providers): correct defects found auditing the attachment and SDK changes The mechanical rewrite that added `isFunctionToolCall` to every `tool_calls` read also rewrote three truthiness guards, where the filtered array was computed, discarded, and the unfiltered value used in the body. Filter once and use that value. The helper also landed between `trackForcedToolUsage`'s TSDoc block and its declaration, leaving that block documenting the wrong function. Raise the Bedrock ceiling from 3.75 MB to 4.5 MB. Converse caps an image at 3.75 MB and a document at 4.5 MB, and a single `maxBytes` cannot express both. Taking the lower bound looked conservative but regressed 3.75-4.5 MB documents, which Converse accepts and which work today. At the document bound every size that works now still works, and only genuinely-too-large files are rejected early; oversized images in that band keep surfacing as a Bedrock API error, exactly as they do without the entry. Both limits re-verified verbatim against the primary docs: Converse's Message reference ("Each image's size ... no more than 3.75 MB", "Each document's size must be no more than 4.5 MB") and Fireworks' vision guide ("Total base64-encoded images must be less than 10MB"). * fix(providers): keep every attachment that works today working Auditing the routing change for backwards compatibility turned up two bands it silently broke. Lowering the single inline cap made the upload path mandatory above ~6 MB, but every large-file path reads its bytes back out of cloud object storage. A deployment without it — local dev, any disk-backed self-host — inlines those files as base64 today and would have started failing outright with "requires cloud file storage". Split the one number in two: the inline ceiling stays at 10 MiB, and a separate threshold marks where an upload becomes *preferable* because the base64 copy no longer fits the payload store. Where no upload path is reachable, base64 hydration now runs to the inline ceiling as before, and a missing cloud-storage backend leaves the file for the inline path instead of throwing. The two strategies also cross over at different sizes now. `files-api` carries every type the provider already accepts, so it takes over at the lower threshold. `remote-url` only fetches images and PDFs, so switching early would have started rejecting 6-10 MB text documents that inline fine today; it takes over only once inlining is genuinely impossible. Revert the Groq ceilings. Its published "20MB" governs a request carrying an image URL, and on this path the body holds only the URL, so it cannot bind on the files maxBytes guards. Groq documents no limit on the image it fetches, so tightening the per-file cap to 20,000,000 and summing raw bytes against the request cap would both reject uploads that work today on no documented basis. * fix(providers): drop the provider ceiling changes and close the audit findings A six-agent line-by-line audit against the vendors' live docs found the `models.ts` ceiling work was not the strict improvement it was written as, so all of it is reverted: - bedrock's 4.5 MB cap broke video. Converse takes image, document AND video blocks, and video is allowed 25 MB base64 — a single `maxBytes` cannot express three content classes, and every 4.5-10 MiB `.mp4` that works today would have started failing. - openai's combined 50 MB cap is the FILE-input limit. Image inputs are governed separately at 512 MB / 1500 images, so summing every attachment rejected eight 8 MB PNGs that OpenAI documents as legal. - fireworks' per-file ceiling was unreachable behind the request budget, while the upload picker went on advertising it — a size the UI accepts and execution always rejects. - The whole `perRequestMaxBytes` feature goes with them: it summed raw bytes against caps that are variously on encoded bytes, on one content class, or on a body that carries only URLs, and it double-counted a file referenced from several messages even though the uploader dedupes by key. Only openai's per-file `maxBytes` stays corrected, to decimal 50,000,000 — the one number a vendor states unambiguously and writes no MiB against. Also fixed, all found by the same audit: The hydration cap stopped short of where `remote-url` actually switches over, so 6-10 MiB attachments on anthropic/openrouter/xai/groq/together/baseten/vllm had neither base64 nor a handle and failed outright — the very band this branch exists to fix. Both decisions now come from one function so they cannot drift. Eight more sites where the mechanical rewrite computed a filtered array and then read the unfiltered one (deepseek, sakana, nvidia, kimi), leaving those providers without the narrowing they appear to have. `isFunctionToolCall` threw on a null or primitive `tool_calls` entry, because `in` requires an object — reachable exactly on the self-hosted gateways this filter was added for. It is now total, and all 32 test mocks match it rather than being quietly more permissive. `checkForForcedToolUsage` in utils/litellm/mistral evaluated the response before the `tool_choice` test, turning a tolerated malformed body into a TypeError on a path that never used to touch it. Gemini: `satisfies` restores the excess-property checking the dropped annotations removed, the poll loop recognises the terminal statuses v2 added instead of spinning for an hour and reporting a timeout, and the streaming doc block no longer names five events that were renamed six lines below it. * fix(providers): report attachment limits in the unit vendors publish The size ceilings are decimal MB — that is how OpenAI, AWS and Fireworks all write them — but the error messages divided by 1024², so OpenAI's 50 MB cap was reported to the user as "48MB". Someone shrinking a 49 MB file to get under it was chasing a limit that does not exist. One formatter, used by all three messages, so the file size and the ceiling in the same sentence are always in the same unit. * fix(providers): derive the limit unit from the ceiling it belongs to The previous commit fixed OpenAI's "48MB" by dividing every ceiling by 10⁶ — which broke the other seven. Only OpenAI's constant is decimal; anthropic, google, together and openrouter are 50 MiB, baseten and vllm 25 MiB, groq and xai 20 MiB. Rendering those as decimal MB overstated each by ~5%, so a 21 MB file on groq was rejected with "(21MB) exceeds the 21MB limit" — a sentence that contradicts itself and sends the user to shrink a file to a size that is still over. Same class of bug as the one being fixed, sign flipped. Both figures now render through one unit taken from the ceiling, so the number a user is told is the number the vendor publishes and the two sizes in a sentence are always comparable. Tested against every ceiling in the registry rather than only the values that happened to round cleanly. Two more from the same audit: A file with a missing or zero declared size was stranded on a files-api provider: hydration bailed on the real byte length while `shouldUseLargeFilePath` saw `0 > threshold` as false, so it got neither base64 nor a handle and failed as "may no longer be accessible" — a size failure wearing an access failure's message. Uploads read the real bytes and enforce the ceiling themselves, so an unknown size now routes to one. The oversized-attachment error blamed the provider for a deployment problem: a files-api provider on a host without cloud storage reported that the provider "has no large-file upload path", which is not true of the provider. `isFunctionToolCall` only proves `function` is present, never that it is well formed, so the trace enricher is defensive again about a hollow payload without giving up the compile-time gate. The 32 test mocks now match production exactly. * fix(providers): stop an over-limit size rendering as the limit itself Deriving the unit from the ceiling fixed the 5% error but left the precision fixed at two decimals, so a file one byte over a 20 MiB cap still printed "(20.00MB) exceeds the 20MB agent attachment limit" — the same self-contradicting sentence, now in a ~5 KB band above every ceiling in the registry. The size rounds up and the ceiling rounds down, so the two can no longer collide. The test that was supposed to guard this asserted a file 0.03MB over and an OpenAI file that was under the limit — neither anywhere near the band — so it passed while the bug was live. It now walks `limit + 1` for every ceiling, and goes red against the old rounding. The reason clause added last commit also claimed a deployment had no cloud file storage whenever the strategy was not inline. A generated document on a remote-url provider reaches that same error with storage fully configured, because a signed URL points at the generation source rather than the rendered artifact — so it was told something false about its own deployment. That case now names itself. * fix(providers): order the attachment failure reason by how general the cause is The generated-document arm was checked first, so it won over both other causes and told users two things that were not true. On an inline-strategy provider — bedrock, mistral, ollama, fireworks, litellm, vertex, kimi — there is no upload path for any file, generated or not, but the message blamed the document format and implied a plain PDF would go through. On openai or google with cloud storage unconfigured it was simply false: a generated document does take the Files API path there, and that exact file uploads fine once storage exists. The one actionable fix was hidden from the operator. A provider with no upload path 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 now outrank the format-specific case — which is left saying only what is true of it: a signed URL points at the generation source rather than the rendered file. The formatter is unchanged. It was brute-forced over every real ceiling and three million random pairs with no collision or inversion, but the test's six ceilings all divide to exact integers, so floor, round and ceil are indistinguishable on them and the limit-side rounding was unpinned. A ceiling with a fractional remainder now covers it.
1 parent ed17bb2 commit d05289c

65 files changed

Lines changed: 914 additions & 220 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/knowledge/search/route.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ vi.mock('@/lib/tokenization/estimators', () => ({
4444
}))
4545

4646
vi.mock('@/providers/utils', () => ({
47+
isFunctionToolCall: (toolCall: unknown) =>
48+
typeof toolCall === 'object' &&
49+
toolCall !== null &&
50+
'function' in toolCall &&
51+
(toolCall as { function?: unknown }).function != null,
4752
calculateCost: vi.fn().mockReturnValue({
4853
input: 0.00001042,
4954
output: 0,

apps/sim/app/api/providers/baseten/models/route.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ const {
1717
}))
1818

1919
vi.mock('@/providers/utils', () => ({
20+
isFunctionToolCall: (toolCall: unknown) =>
21+
typeof toolCall === 'object' &&
22+
toolCall !== null &&
23+
'function' in toolCall &&
24+
(toolCall as { function?: unknown }).function != null,
2025
filterBlacklistedModels: mockFilterBlacklistedModels,
2126
isProviderBlacklisted: mockIsProviderBlacklisted,
2227
}))

apps/sim/app/api/providers/ollama-cloud/models/route.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const {
1919
}))
2020

2121
vi.mock('@/providers/utils', () => ({
22+
isFunctionToolCall: (toolCall: unknown) =>
23+
typeof toolCall === 'object' &&
24+
toolCall !== null &&
25+
'function' in toolCall &&
26+
(toolCall as { function?: unknown }).function != null,
2227
filterBlacklistedModels: mockFilterBlacklistedModels,
2328
isProviderBlacklisted: mockIsProviderBlacklisted,
2429
}))

apps/sim/app/api/providers/together/models/route.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const {
1919
}))
2020

2121
vi.mock('@/providers/utils', () => ({
22+
isFunctionToolCall: (toolCall: unknown) =>
23+
typeof toolCall === 'object' &&
24+
toolCall !== null &&
25+
'function' in toolCall &&
26+
(toolCall as { function?: unknown }).function != null,
2227
filterBlacklistedModels: mockFilterBlacklistedModels,
2328
isProviderBlacklisted: mockIsProviderBlacklisted,
2429
}))

apps/sim/blocks/utils.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ vi.mock('@/providers/models', () => ({
4545
}))
4646

4747
vi.mock('@/providers/utils', () => ({
48+
isFunctionToolCall: (toolCall: unknown) =>
49+
typeof toolCall === 'object' &&
50+
toolCall !== null &&
51+
'function' in toolCall &&
52+
(toolCall as { function?: unknown }).function != null,
4853
getProviderFromModel: vi.fn(() => 'openai'),
4954
}))
5055

apps/sim/ee/access-control/utils/permission-check.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ vi.mock('@/lib/permission-groups/types', () => ({
6666
}))
6767

6868
vi.mock('@/providers/utils', () => ({
69+
isFunctionToolCall: (toolCall: unknown) =>
70+
typeof toolCall === 'object' &&
71+
toolCall !== null &&
72+
'function' in toolCall &&
73+
(toolCall as { function?: unknown }).function != null,
6974
getProviderFromModel: mockGetProviderFromModel,
7075
}))
7176

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import { executeTool } from '@/tools'
3030
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
3131

3232
vi.mock('@/providers/utils', () => ({
33+
isFunctionToolCall: (toolCall: unknown) =>
34+
typeof toolCall === 'object' &&
35+
toolCall !== null &&
36+
'function' in toolCall &&
37+
(toolCall as { function?: unknown }).function != null,
3338
getProviderFromModel: vi.fn().mockReturnValue('mock-provider'),
3439
transformBlockTool: vi.fn(),
3540
getBaseModelProviders: vi.fn().mockReturnValue({ openai: {}, anthropic: {} }),

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,15 @@ import { stringifyJSON } from '@/executor/utils/json'
5353
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
5454
import { executeProviderRequest } from '@/providers'
5555
import {
56-
INLINE_ATTACHMENT_THRESHOLD_BYTES,
56+
formatAttachmentSizes,
57+
getProviderFileStrategy,
5758
shouldUseLargeFilePath,
5859
supportsFileAttachments,
5960
} from '@/providers/attachments'
61+
import {
62+
canUseProviderLargeFilePath,
63+
getInlineHydrationMaxBytes,
64+
} from '@/providers/file-attachments.server'
6065
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
6166
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
6267
import type { SerializedBlock } from '@/serializer/types'
@@ -946,6 +951,8 @@ export class AgentBlockHandler implements BlockHandler {
946951
const requestId = ctx.executionId || ctx.workflowId || 'agent-files'
947952
const nextMessages = [...messages]
948953

954+
const inlineMaxBytes = getInlineHydrationMaxBytes(providerId)
955+
949956
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
950957
const message = messages[messageIndex]
951958
if (!message.files?.length) {
@@ -963,15 +970,37 @@ export class AgentBlockHandler implements BlockHandler {
963970
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
964971
userId: ctx.userId,
965972
logger,
966-
maxBytes: INLINE_ATTACHMENT_THRESHOLD_BYTES,
973+
maxBytes: inlineMaxBytes,
967974
})
968975

969976
const missingFile = hydratedFiles.find(
970-
(file) => !file.base64 && !shouldUseLargeFilePath(file, providerId)
977+
(file) =>
978+
!file.base64 &&
979+
!(canUseProviderLargeFilePath(providerId) && shouldUseLargeFilePath(file, providerId))
971980
)
972981
if (missingFile) {
982+
const { size: sizeMB, limit: inlineMB } = formatAttachmentSizes(
983+
missingFile.size,
984+
inlineMaxBytes
985+
)
986+
const oversized = Number.isFinite(missingFile.size) && missingFile.size > inlineMaxBytes
987+
/**
988+
* Ordered by how general the cause is. A provider with no upload path at all cannot be
989+
* helped by changing the file, and a deployment with no object storage cannot reach any
990+
* upload path whatever the file is — so both outrank the format-specific case. Leading
991+
* with the generated-document arm blamed the document on providers that have no upload
992+
* path for anything, and on hosts whose only real problem was unconfigured storage.
993+
*/
994+
const reason =
995+
getProviderFileStrategy(providerId) === 'inline'
996+
? `provider "${providerId}" has no large-file upload path`
997+
: !canUseProviderLargeFilePath(providerId)
998+
? 'this deployment has no cloud file storage for the large-file upload path'
999+
: `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`
9731000
throw new Error(
974-
`File "${missingFile.name}" could not be read for provider "${providerId}". The file may exceed the attachment size limit or may no longer be accessible.`
1001+
oversized
1002+
? `File "${missingFile.name}" (${sizeMB}MB) exceeds the ${inlineMB}MB inline attachment limit, and ${reason}.`
1003+
: `File "${missingFile.name}" could not be read for provider "${providerId}". The file may no longer be accessible.`
9751004
)
9761005
}
9771006

apps/sim/executor/handlers/pi/keys.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ vi.mock('@/lib/api-key/byok', () => ({
1818
getBYOKKey: mockGetBYOKKey,
1919
}))
2020
vi.mock('@/providers/utils', () => ({
21+
isFunctionToolCall: (toolCall: unknown) =>
22+
typeof toolCall === 'object' &&
23+
toolCall !== null &&
24+
'function' in toolCall &&
25+
(toolCall as { function?: unknown }).function != null,
2126
calculateCost: mockCalculateCost,
2227
shouldBillModelUsage: mockShouldBill,
2328
}))

apps/sim/executor/handlers/pi/pi-handler.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ vi.mock('@/providers/pi-providers', () => ({
7777
resolvePiModelId: mockResolvePiModelId,
7878
}))
7979
vi.mock('@/providers/utils', () => ({
80+
isFunctionToolCall: (toolCall: unknown) =>
81+
typeof toolCall === 'object' &&
82+
toolCall !== null &&
83+
'function' in toolCall &&
84+
(toolCall as { function?: unknown }).function != null,
8085
getProviderFromModel: mockGetProviderFromModel,
8186
}))
8287
vi.mock('@/blocks/utils', () => ({

0 commit comments

Comments
 (0)