From 2a801219298e9d186d22a2d0c471ff2b01f043b1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 16:16:23 -0700 Subject: [PATCH 01/10] fix(providers): route 6-10MB attachments to the provider large-file path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../executor/handlers/agent/agent-handler.ts | 7 +- .../utils/user-file-base64.server.test.ts | 34 +++++ .../uploads/utils/user-file-base64.server.ts | 63 +++++--- apps/sim/providers/attachments.test.ts | 14 +- apps/sim/providers/attachments.ts | 17 ++- .../providers/file-attachments.server.test.ts | 141 ++++++++++++++++++ apps/sim/providers/file-attachments.server.ts | 33 ++++ apps/sim/providers/models.ts | 46 +++++- 8 files changed, 325 insertions(+), 30 deletions(-) create mode 100644 apps/sim/providers/file-attachments.server.test.ts diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 31a2268a404..255141131ec 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -970,8 +970,13 @@ export class AgentBlockHandler implements BlockHandler { (file) => !file.base64 && !shouldUseLargeFilePath(file, providerId) ) if (missingFile) { + const inlineMB = (INLINE_ATTACHMENT_THRESHOLD_BYTES / (1024 * 1024)).toFixed(0) + const oversized = + Number.isFinite(missingFile.size) && missingFile.size > INLINE_ATTACHMENT_THRESHOLD_BYTES 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.` ) } diff --git a/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts b/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts index 6e62f378da9..38338975be0 100644 --- a/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts +++ b/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts @@ -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({ diff --git a/apps/sim/lib/uploads/utils/user-file-base64.server.ts b/apps/sim/lib/uploads/utils/user-file-base64.server.ts index d9195d47c2e..9ef2c2243ba 100644 --- a/apps/sim/lib/uploads/utils/user-file-base64.server.ts +++ b/apps/sim/lib/uploads/utils/user-file-base64.server.ts @@ -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' @@ -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 @@ -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 = { @@ -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) } }, diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index 6ac38dc2b76..5010a031d14 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -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, @@ -286,6 +287,16 @@ describe('provider attachments', () => { }) describe('provider large-file capability', () => { + /** + * Guards the regression where the inline cap (10 MB) sat above what the payload store could + * hold once base64 inflated it, so every 6-10 MB attachment died with "Execution memory limit + * exceeded" instead of taking the provider's large-file path. + */ + it('keeps the inline cap inside the payload store ceiling once base64-encoded', () => { + const encodedBytes = Math.ceil(INLINE_ATTACHMENT_THRESHOLD_BYTES / 3) * 4 + expect(encodedBytes).toBeLessThanOrEqual(LARGE_VALUE_THRESHOLD_BYTES) + }) + it('reports per-provider strategy and ceiling, defaulting others to inline', () => { expect(getProviderFileStrategy('openai')).toBe('files-api') expect(getProviderFileStrategy('google')).toBe('files-api') @@ -298,8 +309,9 @@ 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 an image at 3.75MB — below the inline cap, so it needs its own entry. */ + expect(getProviderAttachmentMaxBytes('bedrock')).toBe(3_750_000) }) it('routes only oversized files on capable providers to the large-file path', () => { diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index 0cc4ebc5d6b..f8826883e21 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -76,9 +76,9 @@ type ProviderFormattedMessage = { } /** - * 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. + * Files at or below this size are inlined as base64; larger files take the provider's + * large-file path. Sized to the execution payload store, not to any provider — see + * {@link INLINE_ATTACHMENT_MAX_BYTES}. */ export const INLINE_ATTACHMENT_THRESHOLD_BYTES = INLINE_ATTACHMENT_MAX_BYTES @@ -202,6 +202,17 @@ export function getProviderAttachmentMaxBytes(providerId: ProviderId | string): return getProviderFileAttachment(providerId).maxBytes } +/** + * Combined attachment ceiling for one request, or `null` when the provider documents none. + * Separate from {@link getProviderAttachmentMaxBytes}: a provider can accept a 50MB file yet + * still reject three 20MB files in the same call. + */ +export function getProviderRequestAttachmentMaxBytes( + providerId: ProviderId | string +): number | null { + return getProviderFileAttachment(providerId).perRequestMaxBytes ?? null +} + export function inferAttachmentMimeType(file: UserFile): string { const explicitType = file.type?.trim().toLowerCase() return resolveFileType({ diff --git a/apps/sim/providers/file-attachments.server.test.ts b/apps/sim/providers/file-attachments.server.test.ts new file mode 100644 index 00000000000..33baee741cd --- /dev/null +++ b/apps/sim/providers/file-attachments.server.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildOpenAIMessageContent } from '@/providers/attachments' +import { + attachLargeFileRemoteUrls, + uploadLargeFilesToProvider, +} from '@/providers/file-attachments.server' +import type { ProviderRequest } from '@/providers/types' + +const { + mockDownloadServableFileFromStorage, + mockGeneratePresignedDownloadUrl, + mockHasCloudStorage, + mockVerifyFileAccess, +} = vi.hoisted(() => ({ + mockDownloadServableFileFromStorage: vi.fn(), + mockGeneratePresignedDownloadUrl: vi.fn(), + mockHasCloudStorage: vi.fn(), + mockVerifyFileAccess: vi.fn(), +})) + +vi.mock('@google/genai', () => ({ + FileState: { PROCESSING: 'PROCESSING', FAILED: 'FAILED' }, + GoogleGenAI: class {}, +})) + +vi.mock('@/lib/uploads', () => ({ + StorageService: { + hasCloudStorage: mockHasCloudStorage, + generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, + }, +})) + +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadServableFileFromStorage: mockDownloadServableFileFromStorage, +})) + +vi.mock('@/app/api/files/authorization', () => ({ + verifyFileAccess: mockVerifyFileAccess, +})) + +/** The exact file from the reported failure: 9,591,617 bytes — over 6 MiB, under 50 MB. */ +const CSV_BYTES = 9_591_617 + +function makeRequest(size: number): ProviderRequest { + return { + model: 'gpt-4.1', + apiKey: 'sk-test', + userId: 'user-1', + workflowId: 'workflow-1', + messages: [ + { + role: 'user', + content: 'what does this say', + files: [ + { + id: 'file-1', + name: 'data_10mb.csv', + key: 'workspace/2f1d8c3e-5b6a-4c7d-8e9f-0a1b2c3d4e5f/data_10mb.csv', + url: '', + size, + type: 'text/csv', + context: 'workspace', + }, + ], + }, + ], + } as unknown as ProviderRequest +} + +describe('OpenAI large-file attachment lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHasCloudStorage.mockReturnValue(true) + mockVerifyFileAccess.mockResolvedValue(true) + mockGeneratePresignedDownloadUrl.mockResolvedValue('https://storage.example.com/signed') + mockDownloadServableFileFromStorage.mockResolvedValue({ + buffer: Buffer.alloc(CSV_BYTES, 0x61), + contentType: 'text/csv', + }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ id: 'file-abc' }), { status: 200 })) + ) + }) + + it('uploads to the Files API and references the file by id instead of inlining it', async () => { + const request = makeRequest(CSV_BYTES) + + await attachLargeFileRemoteUrls(request, 'openai') + await uploadLargeFilesToProvider(request, 'openai') + + const [url, init] = (fetch as unknown as ReturnType).mock.calls[0] + expect(url).toBe('https://api.openai.com/v1/files') + expect(init.method).toBe('POST') + expect(init.headers.Authorization).toBe('Bearer sk-test') + + const form = init.body as FormData + expect(form.get('purpose')).toBe('user_data') + expect(form.get('expires_after[anchor]')).toBe('created_at') + expect(form.get('expires_after[seconds]')).toBe('3600') + expect((form.get('file') as File).size).toBe(CSV_BYTES) + + const file = request.messages?.[0].files?.[0] + expect(file?.providerFileId).toBe('file-abc') + + const content = buildOpenAIMessageContent( + 'what does this say', + request.messages?.[0].files, + 'openai' + ) + expect(content).toEqual([ + { type: 'input_text', text: 'what does this say' }, + { type: 'input_file', file_id: 'file-abc' }, + ]) + }) + + it('leaves files at or below the inline cap on the base64 path', async () => { + const request = makeRequest(5 * 1024 * 1024) + + await attachLargeFileRemoteUrls(request, 'openai') + await uploadLargeFilesToProvider(request, 'openai') + + expect(fetch).not.toHaveBeenCalled() + expect(request.messages?.[0].files?.[0].providerFileId).toBeUndefined() + expect(request.messages?.[0].files?.[0].remoteUrl).toBeUndefined() + }) + + it('rejects a request whose attachments together exceed the combined ceiling', async () => { + const request = makeRequest(30 * 1024 * 1024) + const [first] = request.messages?.[0].files ?? [] + request.messages?.[0].files?.push({ ...first, id: 'file-2', key: `${first.key}-2` }) + + await expect(attachLargeFileRemoteUrls(request, 'openai')).rejects.toThrow( + /total 60.00MB, which exceeds the 48MB combined attachment limit/ + ) + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 4ca03ef07aa..7293e1c131a 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -10,6 +10,7 @@ import type { UserFile } from '@/executor/types' import { getProviderAttachmentMaxBytes, getProviderFileStrategy, + getProviderRequestAttachmentMaxBytes, inferAttachmentMimeType, shouldUseLargeFilePath, } from '@/providers/attachments' @@ -53,6 +54,8 @@ export async function attachLargeFileRemoteUrls( file.remoteUrl = undefined } + assertRequestAttachmentBudget(request, providerId) + if (getProviderFileStrategy(providerId) === 'inline') return const requestId = request.workflowId ?? 'provider-request' @@ -98,6 +101,36 @@ export async function attachLargeFileRemoteUrls( } } +/** + * Rejects a request whose attachments together exceed the provider's combined ceiling. Per-file + * validation cannot catch this: three 20MB files each clear OpenAI's 50MB per-file limit but + * blow its 50MB per-request limit, and the provider answers with an opaque API error late in + * the run — after Sim has already paid to upload every one of them. + */ +function assertRequestAttachmentBudget( + request: ProviderRequest, + providerId: ProviderId | string +): void { + const perRequestMaxBytes = getProviderRequestAttachmentMaxBytes(providerId) + if (perRequestMaxBytes === null) return + + let totalBytes = 0 + let fileCount = 0 + for (const file of iterateRequestFiles(request.messages)) { + if (!Number.isFinite(file.size)) continue + totalBytes += file.size + fileCount++ + } + + if (totalBytes > perRequestMaxBytes) { + const totalMB = (totalBytes / (1024 * 1024)).toFixed(2) + const maxMB = (perRequestMaxBytes / (1024 * 1024)).toFixed(0) + throw new Error( + `The ${fileCount} attachments in this request total ${totalMB}MB, which exceeds the ${maxMB}MB combined attachment limit for provider "${providerId}". Remove or shrink some files.` + ) + } +} + /** * For `files-api` providers, uploads each large attachment (already carrying a signed * `remoteUrl` from {@link attachLargeFileRemoteUrls}) to the provider Files API and records diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 98be92cd78d..31eac89f63d 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -33,6 +33,7 @@ import { xAIIcon, ZaiIcon, } from '@/components/icons' +import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref' import type { ModelPricing, ProviderId } from '@/providers/types' /** How a model's thinking appears on the agent-events stream. */ @@ -131,13 +132,27 @@ export interface ProviderDefinition { export type ProviderFileAttachmentStrategy = 'inline' | 'files-api' | 'remote-url' export interface ProviderFileAttachment { - /** Maximum attachment size the provider accepts, in bytes. */ + /** Maximum size of a single attachment the provider accepts, in bytes. */ maxBytes: number + /** + * Combined ceiling across every attachment in one request, when the provider documents one + * separately from {@link maxBytes} (OpenAI, for example, caps a request at 50 MB total no + * matter how the files divide it). Omitted when the provider documents no combined limit. + */ + perRequestMaxBytes?: 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 +/** + * Inline base64 attachment cap, also the fallback limit for providers without a large-file path. + * + * Bounded by the execution payload store rather than by any provider. Base64 inflates bytes by + * 4/3 and a single stored value may not exceed {@link LARGE_VALUE_THRESHOLD_BYTES}, so the + * largest raw file whose base64 still fits is three quarters of that ceiling. A larger cap does + * not send a bigger file — it fails the run with "Execution memory limit exceeded" partway + * through hydration instead of routing the file to the provider's large-file path. + */ +export const INLINE_ATTACHMENT_MAX_BYTES = Math.floor(LARGE_VALUE_THRESHOLD_BYTES / 4) * 3 const DEFAULT_FILE_ATTACHMENT: ProviderFileAttachment = { maxBytes: INLINE_ATTACHMENT_MAX_BYTES, @@ -152,6 +167,15 @@ export function getProviderFileAttachment(providerId: string): ProviderFileAttac export const PROVIDER_DEFINITIONS: Record = { fireworks: { id: 'fireworks', + /** + * "Total base64-encoded images must be less than 10MB" — a budget on the encoded bytes, so + * the raw-byte equivalent this check sums is three quarters of it. + */ + fileAttachment: { + maxBytes: INLINE_ATTACHMENT_MAX_BYTES, + perRequestMaxBytes: 7_500_000, + strategy: 'inline', + }, name: 'Fireworks', description: 'Fast inference for open-source models via Fireworks AI', defaultModel: '', @@ -301,7 +325,8 @@ export const PROVIDER_DEFINITIONS: Record = { }, openai: { id: 'openai', - fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'files-api' }, + /** "each file must be under 50 MB. The combined limit across all files in the request is 50 MB." */ + fileAttachment: { maxBytes: 50_000_000, perRequestMaxBytes: 50_000_000, strategy: 'files-api' }, name: 'OpenAI', description: "OpenAI's models", defaultModel: 'gpt-4.1', @@ -2418,7 +2443,12 @@ export const PROVIDER_DEFINITIONS: Record = { }, groq: { id: 'groq', - fileAttachment: { maxBytes: 20 * 1024 * 1024, strategy: 'remote-url' }, + /** "Maximum allowed size for a request containing an image URL as input is 20MB." */ + fileAttachment: { + maxBytes: 20_000_000, + perRequestMaxBytes: 20_000_000, + strategy: 'remote-url', + }, name: 'Groq', description: "Groq's LLM models with high-performance inference", defaultModel: 'groq/llama-3.3-70b-versatile', @@ -3475,6 +3505,12 @@ export const PROVIDER_DEFINITIONS: Record = { }, bedrock: { id: 'bedrock', + /** + * Converse caps an image at 3.75 MB and a document at 4.5 MB; the lower bound is the safe + * single ceiling. There is no large-file path: the only non-inline source is `s3Location`, + * which takes an `s3://` URI read with the caller's IAM role, not a presigned HTTPS URL. + */ + fileAttachment: { maxBytes: 3_750_000, strategy: 'inline' }, name: 'AWS Bedrock', description: 'AWS Bedrock foundation models', defaultModel: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', From 8549328871f30b14b993090f8215159bccce00df Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 16:27:13 -0700 Subject: [PATCH 02/10] chore(deps): upgrade @google/genai to 2.13.0 and @anthropic-ai/sdk to 0.115.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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. --- apps/sim/package.json | 4 +- apps/sim/providers/gemini/core.ts | 62 ++++++++++++++++--------------- bun.lock | 10 +++-- 3 files changed, 41 insertions(+), 35 deletions(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index 1f1bbb5e812..f8045c8d68c 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -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", @@ -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", diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 65cc21c5cb7..2e75a048473 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -476,18 +476,20 @@ function collapseMessagesToInput(request: ProviderRequest): { } /** - * Extracts text content from a completed interaction's outputs array. - * The outputs array can contain text, thought, google_search_result, and other types. - * We concatenate all text outputs to get the full research report. + * Extracts the report text from a completed interaction's step timeline. + * + * The v2 Interactions schema replaced the flat `outputs` array with `steps`, a + * type-discriminated timeline: the model's prose lives in `model_output` steps as text + * content, alongside thought, tool-call, and tool-result steps we deliberately skip. */ -function extractTextFromInteractionOutputs(outputs: Interactions.Interaction['outputs']): string { - if (!outputs || outputs.length === 0) return '' +function extractTextFromInteractionSteps(steps: Interactions.Interaction['steps']): string { + if (!steps || steps.length === 0) return '' const textParts: string[] = [] - for (const output of outputs) { - if (output.type === 'text') { - const text = (output as Interactions.TextContent).text - if (text) textParts.push(text) + for (const step of steps) { + if (step.type !== 'model_output') continue + for (const content of step.content ?? []) { + if (content.type === 'text' && content.text) textParts.push(content.text) } } @@ -506,10 +508,7 @@ interface DeepResearchUsage { /** * Extracts token usage from an Interaction's Usage object. * The Interactions API provides total_input_tokens, total_output_tokens, total_tokens, - * total_cached_tokens, and total_reasoning_tokens (for thinking models). - * - * Also handles the raw API field name total_thought_tokens which the SDK may - * map to total_reasoning_tokens. + * total_cached_tokens, and total_thought_tokens (for thinking models). * * The Interactions API supports implicit caching, and `total_cached_tokens` is a * subset of `total_input_tokens` there just as `cachedContentTokenCount` is of @@ -525,10 +524,7 @@ function extractInteractionUsage(usage: Interactions.Usage | undefined): DeepRes const inputTokens = usage.total_input_tokens ?? 0 const outputTokens = usage.total_output_tokens ?? 0 - const reasoningTokens = - usage.total_reasoning_tokens ?? - ((usage as Record).total_thought_tokens as number) ?? - 0 + const reasoningTokens = usage.total_thought_tokens ?? 0 const cachedTokens = usage.total_cached_tokens ?? 0 const totalTokens = usage.total_tokens ?? inputTokens + outputTokens @@ -613,20 +609,20 @@ function createDeepResearchStream( async start(controller) { try { for await (const event of stream) { - if (event.event_type === 'content.delta') { - const delta = (event as Interactions.ContentDelta).delta - if (delta?.type === 'text' && 'text' in delta && delta.text) { + if (event.event_type === 'step.delta') { + const { delta } = event + if (delta?.type === 'text' && delta.text) { fullContent += delta.text controller.enqueue(new TextEncoder().encode(delta.text)) } - } else if (event.event_type === 'interaction.complete') { - const interaction = (event as Interactions.InteractionEvent).interaction + } else if (event.event_type === 'interaction.completed') { + const { interaction } = event if (interaction?.usage) { completionUsage = extractInteractionUsage(interaction.usage) } completedInteractionId = interaction?.id - } else if (event.event_type === 'interaction.start') { - const interaction = (event as Interactions.InteractionEvent).interaction + } else if (event.event_type === 'interaction.created') { + const { interaction } = event if (interaction?.id) { completedInteractionId = interaction.id } @@ -722,9 +718,16 @@ export async function executeDeepResearchRequest( // Streaming mode: create a streaming interaction and return a StreamingExecution if (request.stream) { - const streamParams: Interactions.CreateAgentInteractionParamsStreaming = { + /** + * `stream` is annotated inline rather than via + * `Interactions.CreateAgentInteractionParamsStreaming`: as of @google/genai 2.13.0 that + * namespace alias resolves to `CreateAgentInteraction`, whose `stream` is a plain + * `boolean`. Annotating with it loses the literal that discriminates `interactions.create`'s + * overloads, so the call falls through to the union-returning signature. + */ + const streamParams = { ...baseParams, - stream: true, + stream: true as const, } const streamResponse = await ai.interactions.create( @@ -805,9 +808,10 @@ export async function executeDeepResearchRequest( } // Non-streaming mode: create and poll - const createParams: Interactions.CreateAgentInteractionParamsNonStreaming = { + /** Inline literal for the same overload-discrimination reason as `streamParams` above. */ + const createParams = { ...baseParams, - stream: false, + stream: false as const, } const interaction = await ai.interactions.create( @@ -855,7 +859,7 @@ export async function executeDeepResearchRequest( ) } - const content = extractTextFromInteractionOutputs(result.outputs) + const content = extractTextFromInteractionSteps(result.steps) const usage = extractInteractionUsage(result.usage) logger.info('Deep research completed', { diff --git a/bun.lock b/bun.lock index 43993ccde48..fb4343a6b31 100644 --- a/bun.lock +++ b/bun.lock @@ -144,7 +144,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", @@ -180,7 +180,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", @@ -760,7 +760,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.114.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-zRFGTVMFEm77gt70Q0B+CDRKa9AcguydCcp6bD/dXWv8UkfsVFqCbaqU8+4B/pob3+Vy434LgtrBmwSvxfKr3g=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.115.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ=="], "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], @@ -1190,7 +1190,7 @@ "@google-cloud/storage": ["@google-cloud/storage@7.21.0", "", { "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", "@google-cloud/promisify": "<4.1.0", "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", "mime": "^3.0.0", "p-limit": "^3.0.1", "retry-request": "^7.0.0", "teeny-request": "^9.0.0" } }, "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA=="], - "@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="], + "@google/genai": ["@google/genai@2.13.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-GM7C8Kaomvjz05x5JEO6+l3d/pciL9LxAG9dUjJLD7nTPZ9X0Cfsf2Z7eET6UjgWyUmxXCHtYnQoQ77F9+ZIOQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -4638,6 +4638,8 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], + "@browserbasehq/stagehand/@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="], + "@cerebras/cerebras_cloud_sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], From 3786320c1c2f03f757ed41d3ee7a0266f4e737cd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 16:44:24 -0700 Subject: [PATCH 03/10] chore(deps): upgrade openai to 7.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/api/knowledge/search/route.test.ts | 1 + .../providers/baseten/models/route.test.ts | 1 + .../ollama-cloud/models/route.test.ts | 1 + .../providers/together/models/route.test.ts | 1 + apps/sim/blocks/utils.test.ts | 1 + .../utils/permission-check.test.ts | 1 + .../handlers/agent/agent-handler.test.ts | 1 + apps/sim/executor/handlers/pi/keys.test.ts | 1 + .../executor/handlers/pi/pi-handler.test.ts | 1 + apps/sim/lib/api-key/byok.test.ts | 1 + .../workflow/edit-workflow/validation.test.ts | 1 + apps/sim/lib/model-router/resolve.test.ts | 1 + apps/sim/package.json | 2 +- .../anthropic/streaming-tool-loop.test.ts | 1 + apps/sim/providers/azure-openai/index.test.ts | 1 + apps/sim/providers/azure-openai/index.ts | 19 +++++++++++----- apps/sim/providers/baseten/index.test.ts | 1 + apps/sim/providers/baseten/index.ts | 11 ++++++---- apps/sim/providers/bedrock/index.test.ts | 1 + .../bedrock/streaming-tool-loop.test.ts | 1 + apps/sim/providers/cerebras/index.ts | 13 ++++++----- apps/sim/providers/deepseek/index.test.ts | 1 + apps/sim/providers/deepseek/index.ts | 10 +++++---- apps/sim/providers/fireworks/index.test.ts | 1 + apps/sim/providers/fireworks/index.ts | 11 ++++++---- .../gemini/streaming-tool-loop.test.ts | 1 + apps/sim/providers/groq/index.test.ts | 1 + apps/sim/providers/groq/index.ts | 8 ++++--- apps/sim/providers/kimi/index.ts | 13 ++++++----- apps/sim/providers/litellm/index.test.ts | 1 + apps/sim/providers/litellm/index.ts | 17 +++++++++----- apps/sim/providers/meta/index.ts | 11 ++++++---- apps/sim/providers/mistral/index.test.ts | 1 + apps/sim/providers/mistral/index.ts | 15 ++++++++----- apps/sim/providers/nvidia/index.ts | 15 ++++++++----- apps/sim/providers/ollama-cloud/index.test.ts | 1 + apps/sim/providers/ollama/core.ts | 12 +++++----- apps/sim/providers/ollama/index.test.ts | 1 + .../openai-compat/streaming-tool-loop.test.ts | 1 + .../providers/openai/core.cache-key.test.ts | 1 + .../providers/openai/core.reasoning.test.ts | 1 + .../providers/openai/streaming-tool-loop.ts | 3 ++- apps/sim/providers/openai/utils.ts | 11 +++++++++- apps/sim/providers/openrouter/index.test.ts | 1 + apps/sim/providers/openrouter/index.ts | 11 ++++++---- apps/sim/providers/sakana/index.ts | 15 ++++++++----- .../providers/settled-tool-streams.test.ts | 1 + apps/sim/providers/together/index.test.ts | 1 + apps/sim/providers/together/index.ts | 11 ++++++---- apps/sim/providers/trace-enrichment.ts | 13 +++++++---- apps/sim/providers/utils.ts | 22 ++++++++++++++++++- apps/sim/providers/vllm/index.test.ts | 1 + apps/sim/providers/vllm/index.ts | 10 +++++---- apps/sim/providers/xai/index.ts | 9 +++++--- apps/sim/providers/zai/index.ts | 11 ++++++---- bun.lock | 14 +++++++----- 56 files changed, 223 insertions(+), 96 deletions(-) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index d12fd48e0aa..ae23c7e6b96 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -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, diff --git a/apps/sim/app/api/providers/baseten/models/route.test.ts b/apps/sim/app/api/providers/baseten/models/route.test.ts index fe53568ed51..d65dfa1002a 100644 --- a/apps/sim/app/api/providers/baseten/models/route.test.ts +++ b/apps/sim/app/api/providers/baseten/models/route.test.ts @@ -17,6 +17,7 @@ const { })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts index 3b563a8f7cb..f71268b4ab1 100644 --- a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts @@ -19,6 +19,7 @@ const { })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/app/api/providers/together/models/route.test.ts b/apps/sim/app/api/providers/together/models/route.test.ts index b7516070a0e..7f48a9d8e19 100644 --- a/apps/sim/app/api/providers/together/models/route.test.ts +++ b/apps/sim/app/api/providers/together/models/route.test.ts @@ -19,6 +19,7 @@ const { })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 9d5a3adf24f..1bab16d2d57 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -45,6 +45,7 @@ vi.mock('@/providers/models', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, getProviderFromModel: vi.fn(() => 'openai'), })) diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index c449d3d9608..1a04a2acabd 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -66,6 +66,7 @@ vi.mock('@/lib/permission-groups/types', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, getProviderFromModel: mockGetProviderFromModel, })) diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 981352a69d5..69679abd9af 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -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: {} }), diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 2d33c2a30fa..34d92aafad7 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -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, })) diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index 61110401c4e..550b707d1b0 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -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', () => ({ diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index bcc8d81bfcb..b7901e21cf5 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -42,6 +42,7 @@ vi.mock('@/providers/models', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, PROVIDER_PLACEHOLDER_KEY: 'placeholder', })) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 8ae05a41465..35c39df1274 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -243,6 +243,7 @@ vi.mock('@/lib/workflows/skills/operations', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, getHostedModels: mockGetHostedModels, })) diff --git a/apps/sim/lib/model-router/resolve.test.ts b/apps/sim/lib/model-router/resolve.test.ts index a0cbbccd3a1..90008a72b75 100644 --- a/apps/sim/lib/model-router/resolve.test.ts +++ b/apps/sim/lib/model-router/resolve.test.ts @@ -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, })) diff --git a/apps/sim/package.json b/apps/sim/package.json index f8045c8d68c..9e518dc2c33 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -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", diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts index c0f730af6c1..da87c8d6130 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -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, diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index 44310504746..6f1e1a2edb5 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -76,6 +76,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ enrichLastModelSegmentFromChatCompletions: vi.fn(), })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareTools, diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index bd979cc3d9f..05e4b03e868 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -5,6 +5,7 @@ import { AzureOpenAI } from 'openai' import type { ChatCompletion, ChatCompletionContentPart, + ChatCompletionCreateParams, ChatCompletionCreateParamsBase, ChatCompletionCreateParamsStreaming, ChatCompletionMessageParam, @@ -12,6 +13,10 @@ import type { ChatCompletionToolChoiceOption, } from 'openai/resources/chat/completions' import type { ReasoningEffort } from 'openai/resources/shared' + +/** `verbosity` narrowed from `string` to a literal union in openai v5. */ +type ChatCompletionVerbosity = NonNullable + import { env } from '@/lib/core/config/env' import { createPinnedFetch, validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import type { StreamingExecution } from '@/executor/types' @@ -44,6 +49,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -138,7 +144,7 @@ async function executeChatCompletionsRequest( if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') payload.reasoning_effort = request.reasoningEffort as ReasoningEffort if (request.verbosity !== undefined && request.verbosity !== 'auto') - payload.verbosity = request.verbosity + payload.verbosity = request.verbosity as ChatCompletionVerbosity if (request.responseFormat) { payload.response_format = { @@ -270,7 +276,7 @@ async function executeChatCompletionsRequest( enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'azure_openai' } ) @@ -289,7 +295,8 @@ async function executeChatCompletionsRequest( content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -474,7 +481,7 @@ async function executeChatCompletionsRequest( enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'azure_openai' } ) @@ -495,7 +502,7 @@ async function executeChatCompletionsRequest( if ( iterationCount === MAX_TOOL_ITERATIONS && - currentResponse.choices[0]?.message?.tool_calls?.length + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall)?.length ) { /** * The capped turn still requests tools, so make one tool-disabled call to @@ -531,7 +538,7 @@ async function executeChatCompletionsRequest( enrichLastModelSegmentFromChatCompletions( timeSegments, synthesisResponse, - synthesisResponse.choices[0]?.message?.tool_calls, + synthesisResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'azure_openai' } ) } diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index d9e450ece2f..669e6886d97 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -52,6 +52,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index d33d4d7ec8a..e8893ad02d9 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -31,6 +31,7 @@ import { ProviderError } from '@/providers/types' import { calculateCost, generateSchemaInstructions, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -247,7 +248,8 @@ export const basetenProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -436,7 +438,8 @@ export const basetenProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + const pendingToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { model: request.model, provider: 'baseten', @@ -487,7 +490,7 @@ export const basetenProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'baseten' } ) } @@ -541,7 +544,7 @@ export const basetenProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'baseten' } ) } diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index 17d593d048c..f2cf67235c8 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -38,6 +38,7 @@ vi.mock('@/providers/models', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0, pricing: null }), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts index cf18f0e334c..61dc3970856 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -27,6 +27,7 @@ vi.mock('@/tools', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, prepareToolExecution: vi.fn(() => ({ toolParams: { url: 'https://example.com' }, executionParams: { url: 'https://example.com' }, diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index adbd329b118..0e56187af2b 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -24,6 +24,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -197,7 +198,8 @@ export const cerebrasProvider: ProviderConfig = { const toolCallSignatures = new Set() try { while (iterationCount < MAX_TOOL_ITERATIONS) { - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -353,7 +355,7 @@ export const cerebrasProvider: ProviderConfig = { let usedForcedTools: string[] = [] if (typeof originalToolChoice === 'object' && forcedTools.length > 0) { const toolTracking = trackForcedToolUsage( - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), originalToolChoice, logger, 'openai', @@ -408,7 +410,7 @@ export const cerebrasProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'cerebras' } ) @@ -450,7 +452,8 @@ export const cerebrasProvider: ProviderConfig = { } } - const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + const cappedToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) if (iterationCount === MAX_TOOL_ITERATIONS && cappedToolCalls?.length) { enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -492,7 +495,7 @@ export const cerebrasProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'cerebras' } ) iterationCount++ diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts index ff5cf829824..c9e7ee99e10 100644 --- a/apps/sim/providers/deepseek/index.test.ts +++ b/apps/sim/providers/deepseek/index.test.ts @@ -47,6 +47,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index aaa27ffe123..e2e5b3b752f 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -22,6 +22,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, trackForcedToolUsage, @@ -307,7 +308,7 @@ export const deepseekProvider: ProviderConfig = { if ( typeof originalToolChoice === 'object' && - currentResponse.choices[0]?.message?.tool_calls + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) ) { const toolCallsResponse = currentResponse.choices[0].message.tool_calls const result = trackForcedToolUsage( @@ -328,7 +329,8 @@ export const deepseekProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -524,7 +526,7 @@ export const deepseekProvider: ProviderConfig = { if ( typeof nextPayload.tool_choice === 'object' && - currentResponse.choices[0]?.message?.tool_calls + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) ) { const toolCallsResponse = currentResponse.choices[0].message.tool_calls const result = trackForcedToolUsage( @@ -571,7 +573,7 @@ export const deepseekProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'deepseek' } ) } diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index 563117f4b27..3a7514d5b56 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -55,6 +55,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 12c94ddf48c..8147bb0741f 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -32,6 +32,7 @@ import { ProviderError } from '@/providers/types' import { calculateCost, generateSchemaInstructions, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -253,7 +254,8 @@ export const fireworksProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -442,7 +444,8 @@ export const fireworksProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + const pendingToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { model: request.model, provider: 'fireworks', @@ -493,7 +496,7 @@ export const fireworksProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'fireworks' } ) } @@ -547,7 +550,7 @@ export const fireworksProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'fireworks' } ) } diff --git a/apps/sim/providers/gemini/streaming-tool-loop.test.ts b/apps/sim/providers/gemini/streaming-tool-loop.test.ts index e9b4a020f18..0b14ef395d0 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.test.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.test.ts @@ -28,6 +28,7 @@ vi.mock('@/tools', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, prepareToolExecution: vi.fn(() => ({ toolParams: { url: 'https://httpbin.org/get' }, executionParams: { url: 'https://httpbin.org/get' }, diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts index 7cf12b31de0..6c64f4fe25f 100644 --- a/apps/sim/providers/groq/index.test.ts +++ b/apps/sim/providers/groq/index.test.ts @@ -47,6 +47,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 06e5f431be5..04861fc4d00 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -27,6 +27,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, trackForcedToolUsage, @@ -302,7 +303,8 @@ export const groqProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -450,7 +452,7 @@ export const groqProvider: ProviderConfig = { let usedForcedTools: string[] = [] if (typeof originalToolChoice === 'object' && forcedTools.length > 0) { const toolTracking = trackForcedToolUsage( - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), originalToolChoice, logger, 'openai', @@ -508,7 +510,7 @@ export const groqProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'groq' } ) } diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index ead35a5ddfa..16c7839ac35 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -29,6 +29,7 @@ import { ProviderError } from '@/providers/types' import { calculateCost, enforceStrictSchema, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -275,7 +276,7 @@ export const kimiProvider: ProviderConfig = { if ( typeof originalToolChoice === 'object' && - currentResponse.choices[0]?.message?.tool_calls + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) ) { const toolCallsResponse = currentResponse.choices[0].message.tool_calls const result = trackForcedToolUsage( @@ -296,7 +297,8 @@ export const kimiProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -465,7 +467,7 @@ export const kimiProvider: ProviderConfig = { if ( typeof nextPayload.tool_choice === 'object' && - currentResponse.choices[0]?.message?.tool_calls + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) ) { const toolCallsResponse = currentResponse.choices[0].message.tool_calls const result = trackForcedToolUsage( @@ -507,7 +509,8 @@ export const kimiProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + const cappedToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, @@ -552,7 +555,7 @@ export const kimiProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'kimi' } ) iterationCount++ diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 50563bf21db..af7b7fc6227 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -55,6 +55,7 @@ vi.mock('@/providers/litellm/utils', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), sumToolCosts: vi.fn(() => 0), prepareToolExecution: vi.fn((_tool, toolArgs) => ({ diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index a77e423b367..9973b898a1c 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -27,6 +27,7 @@ import { ProviderError } from '@/providers/types' import { calculateCost, enforceStrictSchema, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -267,7 +268,10 @@ export const litellmProvider: ProviderConfig = { response: any, toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } ) => { - if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) { + if ( + typeof toolChoice === 'object' && + response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) + ) { const toolCallsResponse = response.choices[0].message.tool_calls const result = trackForcedToolUsage( toolCallsResponse, @@ -329,7 +333,8 @@ export const litellmProvider: ProviderConfig = { } } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -533,7 +538,7 @@ export const litellmProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'litellm' } ) } @@ -582,12 +587,12 @@ export const litellmProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'litellm' } ) } else if ( iterationCount === MAX_TOOL_ITERATIONS && - currentResponse.choices[0]?.message?.tool_calls?.length + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall)?.length ) { /** * The capped turn still requests tools, so make one tool-disabled call @@ -623,7 +628,7 @@ export const litellmProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, synthesisResponse, - synthesisResponse.choices[0]?.message?.tool_calls, + synthesisResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'litellm' } ) } diff --git a/apps/sim/providers/meta/index.ts b/apps/sim/providers/meta/index.ts index fc76172a23e..971f1ede2c5 100644 --- a/apps/sim/providers/meta/index.ts +++ b/apps/sim/providers/meta/index.ts @@ -23,6 +23,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -244,7 +245,8 @@ export const metaProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -425,7 +427,8 @@ export const metaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + const cappedToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, @@ -470,7 +473,7 @@ export const metaProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'meta' } ) iterationCount++ @@ -524,7 +527,7 @@ export const metaProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'meta' } ) } diff --git a/apps/sim/providers/mistral/index.test.ts b/apps/sim/providers/mistral/index.test.ts index ec5871ad47a..a46e58d6a48 100644 --- a/apps/sim/providers/mistral/index.test.ts +++ b/apps/sim/providers/mistral/index.test.ts @@ -36,6 +36,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ enrichLastModelSegmentFromChatCompletions: vi.fn(), })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: vi.fn((tools) => ({ diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 52453e72487..e2711faffd0 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -23,6 +23,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -202,7 +203,10 @@ export const mistralProvider: ProviderConfig = { response: any, toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } ) => { - if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) { + if ( + typeof toolChoice === 'object' && + response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) + ) { const toolCallsResponse = response.choices[0].message.tool_calls const result = trackForcedToolUsage( toolCallsResponse, @@ -256,7 +260,8 @@ export const mistralProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -457,11 +462,11 @@ export const mistralProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'mistral' } ) - if (currentResponse.choices[0]?.message?.tool_calls?.length) { + if (currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall)?.length) { /** * The capped turn still requests tools, so make one tool-disabled call * to synthesize an answer from the tool results already gathered. @@ -496,7 +501,7 @@ export const mistralProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, synthesisResponse, - synthesisResponse.choices[0]?.message?.tool_calls, + synthesisResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'mistral' } ) } diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index 885f6049e81..82c389d1e4a 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -24,6 +24,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -226,7 +227,7 @@ export const nvidiaProvider: ProviderConfig = { if ( typeof originalToolChoice === 'object' && - currentResponse.choices[0]?.message?.tool_calls + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) ) { const toolCallsResponse = currentResponse.choices[0].message.tool_calls const result = trackForcedToolUsage( @@ -247,7 +248,8 @@ export const nvidiaProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -416,7 +418,7 @@ export const nvidiaProvider: ProviderConfig = { if ( typeof nextPayload.tool_choice === 'object' && - currentResponse.choices[0]?.message?.tool_calls + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) ) { const toolCallsResponse = currentResponse.choices[0].message.tool_calls const result = trackForcedToolUsage( @@ -458,7 +460,8 @@ export const nvidiaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + const cappedToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, @@ -507,7 +510,7 @@ export const nvidiaProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'nvidia' } ) iterationCount++ @@ -559,7 +562,7 @@ export const nvidiaProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'nvidia' } ) } diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index e199c2ebbf3..67aba8ba6d9 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -72,6 +72,7 @@ vi.mock('@/providers/ollama-cloud/utils', () => ({ }, })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }), generateSchemaInstructions: () => 'SCHEMA_INSTRUCTIONS', prepareToolExecution: (_tool: unknown, args: Record) => ({ diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index 064caf79350..ff62ca8957f 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -23,6 +23,7 @@ import { ProviderError } from '@/providers/types' import { calculateCost, generateSchemaInstructions, + isFunctionToolCall, prepareToolExecution, sumToolCosts, } from '@/providers/utils' @@ -265,7 +266,8 @@ export async function executeOllamaProviderRequest( } } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -454,7 +456,7 @@ export async function executeOllamaProviderRequest( enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: providerId } ) } @@ -501,12 +503,12 @@ export async function executeOllamaProviderRequest( enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: providerId } ) } else if ( iterationCount === MAX_TOOL_ITERATIONS && - currentResponse.choices[0]?.message?.tool_calls?.length + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall)?.length ) { /** * The capped turn still requests tools, so make one tool-disabled call to @@ -542,7 +544,7 @@ export async function executeOllamaProviderRequest( enrichLastModelSegmentFromChatCompletions( timeSegments, synthesisResponse, - synthesisResponse.choices[0]?.message?.tool_calls, + synthesisResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: providerId } ) } diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index a0c358d7559..1447fa42d53 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -59,6 +59,7 @@ vi.mock('@/providers/ollama/utils', () => ({ }, })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }), generateSchemaInstructions: () => 'SCHEMA_INSTRUCTIONS', prepareToolExecution: (_tool: unknown, args: Record) => ({ diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts index b08b4050cba..7164c388dff 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -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, diff --git a/apps/sim/providers/openai/core.cache-key.test.ts b/apps/sim/providers/openai/core.cache-key.test.ts index c12bfa34928..829383ccf6b 100644 --- a/apps/sim/providers/openai/core.cache-key.test.ts +++ b/apps/sim/providers/openai/core.cache-key.test.ts @@ -13,6 +13,7 @@ import type { ProviderRequest } from '@/providers/types' vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: () => ({ input: 0, output: 0, total: 0 }), sumToolCosts: () => 0, enforceStrictSchema: (schema: unknown) => schema, diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts index a9b588951b0..5048fbd88aa 100644 --- a/apps/sim/providers/openai/core.reasoning.test.ts +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -15,6 +15,7 @@ import { executeTool } from '@/tools' vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: () => ({ input: 0, output: 0, total: 0 }), sumToolCosts: () => 0, enforceStrictSchema: (schema: unknown) => schema, diff --git a/apps/sim/providers/openai/streaming-tool-loop.ts b/apps/sim/providers/openai/streaming-tool-loop.ts index 1715891cc63..591f8bb3d37 100644 --- a/apps/sim/providers/openai/streaming-tool-loop.ts +++ b/apps/sim/providers/openai/streaming-tool-loop.ts @@ -11,6 +11,7 @@ import { createOpenAIUsageAccumulator, } from '@/providers/openai/usage' import { + convertResponseOutputToInputItems, extractResponseText, extractResponseToolCalls, isMaxOutputTokensIncompleteResponse, @@ -464,7 +465,7 @@ export function createOpenAIResponsesStreamingToolLoopStream( break } - currentInput.push(...turn.response.output) + currentInput.push(...convertResponseOutputToInputItems(turn.response.output)) if (typeof currentToolChoice === 'object') { for (const toolCall of executableTools) { diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index fa6e295f071..93d05f6b9c8 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -332,11 +332,20 @@ export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutput /** * Converts Responses API output items into input items for subsequent calls. + * + * Echoing output items straight back as input is exactly what the Responses API asks for in a + * tool loop, but the SDK models `ResponseOutputItem` and `ResponseInputItem` as separate unions + * that diverge on members Sim never produces — computer-use call outputs (whose `status` admits + * `failed`, which the input shape rejects) and the `AdditionalTools` escape hatch. Narrowing + * member by member would have to be redone on every SDK bump, so the conversion is asserted + * once, here, and every caller goes through it rather than pushing raw output items. */ export function convertResponseOutputToInputItems( output: OpenAI.Responses.ResponseOutputItem[] ): ResponsesInputItem[] { - return Array.isArray(output) ? output : [] + if (!Array.isArray(output)) return [] + // double-cast-allowed: the SDK's output and input item unions diverge only on members Sim never emits + return output as unknown as ResponsesInputItem[] } /** diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index c611a5ee7f8..9b11c16fa4a 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -60,6 +60,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolsWithUsageControl: mockPrepareTools, prepareToolExecution: vi.fn((_tool: unknown, toolArgs: Record) => ({ diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index e2949fd9dfb..85f4ce7a602 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -38,6 +38,7 @@ import { ProviderError } from '@/providers/types' import { calculateCost, generateSchemaInstructions, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -262,7 +263,8 @@ export const openRouterProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -457,7 +459,8 @@ export const openRouterProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + const pendingToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { model: request.model, provider: 'openrouter', @@ -508,7 +511,7 @@ export const openRouterProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'openrouter' } ) } @@ -562,7 +565,7 @@ export const openRouterProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'openrouter' } ) } diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index d005e4d0fef..498298d5a08 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -23,6 +23,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -223,7 +224,7 @@ export const sakanaProvider: ProviderConfig = { if ( typeof originalToolChoice === 'object' && - currentResponse.choices[0]?.message?.tool_calls + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) ) { const toolCallsResponse = currentResponse.choices[0].message.tool_calls const result = trackForcedToolUsage( @@ -244,7 +245,8 @@ export const sakanaProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -419,7 +421,7 @@ export const sakanaProvider: ProviderConfig = { if ( typeof nextPayload.tool_choice === 'object' && - currentResponse.choices[0]?.message?.tool_calls + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) ) { const toolCallsResponse = currentResponse.choices[0].message.tool_calls const result = trackForcedToolUsage( @@ -461,7 +463,8 @@ export const sakanaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + const cappedToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, @@ -510,7 +513,7 @@ export const sakanaProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'sakana' } ) iterationCount++ @@ -564,7 +567,7 @@ export const sakanaProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'sakana' } ) } diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts index 0cba631abe4..0464829693f 100644 --- a/apps/sim/providers/settled-tool-streams.test.ts +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -69,6 +69,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn(() => ({ input: 1, output: 2, total: 3 })), enforceStrictSchema: vi.fn((schema) => schema), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index 40624dad957..e03712efd87 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -52,6 +52,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index 615a66f967e..5d791eb12a6 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -31,6 +31,7 @@ import { ProviderError } from '@/providers/types' import { calculateCost, generateSchemaInstructions, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -247,7 +248,8 @@ export const togetherProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -436,7 +438,8 @@ export const togetherProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + const pendingToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { model: request.model, provider: 'together', @@ -487,7 +490,7 @@ export const togetherProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'together' } ) } @@ -541,7 +544,7 @@ export const togetherProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'together' } ) } diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts index caa82abf61e..5ffbf5ea1c6 100644 --- a/apps/sim/providers/trace-enrichment.ts +++ b/apps/sim/providers/trace-enrichment.ts @@ -36,7 +36,12 @@ interface ChatCompletionLike { interface ChatCompletionToolCallLike { id: string - function: { name: string; arguments: string } + /** + * Absent on the `custom` tool-call variant the SDK's `ChatCompletionMessageToolCall` union + * gained in v5. Sim only ever declares function tools, so a custom call should never arrive — + * but the response type permits one, and a trace enricher must not throw on it. + */ + function?: { name: string; arguments: string } } /** @@ -110,7 +115,7 @@ export function enrichLastModelSegment( * Parses a tool call's `function.arguments` JSON string into an object, or * returns the raw string if it is not valid JSON. */ -function parseToolCallArguments(rawArguments: string): Record | string { +function parseToolCallArguments(rawArguments = ''): Record | string { try { const parsed = JSON.parse(rawArguments) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { @@ -180,8 +185,8 @@ export function enrichLastModelSegmentFromChatCompletions( const toolCalls: IterationToolCall[] = (toolCallsInResponse ?? []).map((tc) => ({ id: tc.id, - name: tc.function.name, - arguments: parseToolCallArguments(tc.function.arguments), + name: tc.function?.name ?? '', + arguments: parseToolCallArguments(tc.function?.arguments), })) const usage = response.usage diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 12b7c0cc966..49708cbe288 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -1246,6 +1246,23 @@ export function prepareToolsWithUsageControl( * @param usedForcedTools Array of tool IDs that have already been used * @returns Object containing tracking information and next tool choice */ +/** + * Narrows the SDK's `ChatCompletionMessageToolCall` union to its function variant. + * + * v5 of the `openai` SDK widened that union with a `custom` tool call carrying no `function` + * field, so every `.function` access needs narrowing first. Sim only ever declares function + * tools, so a custom call should not arrive. + * + * Deliberately tests for the `function` payload rather than `type === 'function'`: many + * OpenAI-compatible vendors omit `type` on tool calls entirely, and discriminating on it would + * silently drop every tool call those providers return. + */ +export function isFunctionToolCall( + toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall +): toolCall is OpenAI.Chat.Completions.ChatCompletionMessageFunctionToolCall { + return 'function' in toolCall && toolCall.function != null +} + export function trackForcedToolUsage( toolCallsResponse: any[] | undefined, originalToolChoice: any, @@ -1562,7 +1579,10 @@ export function checkForForcedToolUsageOpenAI( let hasUsedForcedTool = false let updatedUsedForcedTools = [...usedForcedTools] - if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) { + if ( + typeof toolChoice === 'object' && + response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) + ) { const toolCallsResponse = response.choices[0].message.tool_calls const result = trackForcedToolUsage( toolCallsResponse, diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index 342067d8022..3e9019007f7 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -60,6 +60,7 @@ vi.mock('@/providers/trace-enrichment', () => ({ enrichLastModelSegmentFromChatCompletions: vi.fn(), })) vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareTools, diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 4d2928266f8..d704d31444f 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -27,6 +27,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -339,7 +340,8 @@ export const vllmProvider: ProviderConfig = { } } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -551,11 +553,11 @@ export const vllmProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'vllm' } ) - if (currentResponse.choices[0]?.message?.tool_calls?.length) { + if (currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall)?.length) { /** * The capped turn still requests tools, so make one tool-disabled call * to synthesize an answer from the tool results already gathered. @@ -593,7 +595,7 @@ export const vllmProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, synthesisResponse, - synthesisResponse.choices[0]?.message?.tool_calls, + synthesisResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'vllm' } ) } diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 292f6111c4a..ebe05b2dc67 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -24,6 +24,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -230,7 +231,8 @@ export const xAIProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -468,7 +470,8 @@ export const xAIProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + const pendingToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, @@ -519,7 +522,7 @@ export const xAIProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + finalResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'xai' } ) } diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts index acc5bb4ec36..d9da39de22f 100644 --- a/apps/sim/providers/zai/index.ts +++ b/apps/sim/providers/zai/index.ts @@ -23,6 +23,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -256,7 +257,8 @@ export const zaiProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } - const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + const toolCallsInResponse = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, @@ -431,7 +433,8 @@ export const zaiProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + const cappedToolCalls = + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, @@ -484,7 +487,7 @@ export const zaiProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'zai' } ) iterationCount++ @@ -539,7 +542,7 @@ export const zaiProvider: ProviderConfig = { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), { model: request.model, provider: 'zai' } ) } diff --git a/bun.lock b/bun.lock index fb4343a6b31..0873de33ce0 100644 --- a/bun.lock +++ b/bun.lock @@ -307,7 +307,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", @@ -3716,7 +3716,7 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], + "openai": ["openai@7.0.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-NaAHnDTmut8tl1Is/T2bmRGQm67fEb9VrxdlVradQoh6WnHNPToQeOZiVjLa7LV9uq0Ha4C5twgOsp4+0UWwYg=="], "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="], @@ -4640,6 +4640,8 @@ "@browserbasehq/stagehand/@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="], + "@browserbasehq/stagehand/openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], + "@cerebras/cerebras_cloud_sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], @@ -5188,8 +5190,6 @@ "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -5336,6 +5336,8 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@browserbasehq/stagehand/openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@cerebras/cerebras_cloud_sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], @@ -5664,8 +5666,6 @@ "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], - "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], @@ -5708,6 +5708,8 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@browserbasehq/stagehand/openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], "@trigger.dev/core/socket.io-client/engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], From 2ba228a659b9f8dac55a50dcde69600ae8030919 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 16:55:31 -0700 Subject: [PATCH 04/10] 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"). --- apps/sim/providers/attachments.test.ts | 4 ++-- apps/sim/providers/azure-openai/index.ts | 7 +++--- apps/sim/providers/litellm/index.ts | 6 ++--- apps/sim/providers/mistral/index.ts | 6 ++--- apps/sim/providers/models.ts | 13 +++++++---- apps/sim/providers/utils.ts | 29 +++++++++++------------- 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index 5010a031d14..62f17509aec 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -310,8 +310,8 @@ describe('provider large-file capability', () => { INLINE_ATTACHMENT_THRESHOLD_BYTES ) expect(getProviderAttachmentMaxBytes('azure-openai')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES) - /** Bedrock Converse caps an image at 3.75MB — below the inline cap, so it needs its own entry. */ - expect(getProviderAttachmentMaxBytes('bedrock')).toBe(3_750_000) + /** 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', () => { diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 05e4b03e868..9ecb86396a3 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -13,10 +13,6 @@ import type { ChatCompletionToolChoiceOption, } from 'openai/resources/chat/completions' import type { ReasoningEffort } from 'openai/resources/shared' - -/** `verbosity` narrowed from `string` to a literal union in openai v5. */ -type ChatCompletionVerbosity = NonNullable - import { env } from '@/lib/core/config/env' import { createPinnedFetch, validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import type { StreamingExecution } from '@/executor/types' @@ -55,6 +51,9 @@ import { sumToolCosts, } from '@/providers/utils' +/** `verbosity` narrowed from `string` to a literal union in openai v5. */ +type ChatCompletionVerbosity = NonNullable + const logger = createLogger('AzureOpenAIProvider') /** diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 9973b898a1c..fae459ec513 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -268,11 +268,9 @@ export const litellmProvider: ProviderConfig = { response: any, toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } ) => { - if ( - typeof toolChoice === 'object' && + const toolCallsResponse = response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = response.choices[0].message.tool_calls + if (typeof toolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, toolChoice, diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index e2711faffd0..0bf00af2505 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -203,11 +203,9 @@ export const mistralProvider: ProviderConfig = { response: any, toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } ) => { - if ( - typeof toolChoice === 'object' && + const toolCallsResponse = response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = response.choices[0].message.tool_calls + if (typeof toolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, toolChoice, diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 31eac89f63d..17d905dbbda 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -3506,11 +3506,16 @@ export const PROVIDER_DEFINITIONS: Record = { bedrock: { id: 'bedrock', /** - * Converse caps an image at 3.75 MB and a document at 4.5 MB; the lower bound is the safe - * single ceiling. There is no large-file path: the only non-inline source is `s3Location`, - * which takes an `s3://` URI read with the caller's IAM role, not a presigned HTTPS URL. + * Converse caps an image at 3.75 MB and a document at 4.5 MB. A single `maxBytes` cannot + * express both, so it carries the higher (document) bound: clamping to 3.75 MB would reject + * 3.75-4.5 MB documents that Converse accepts today, whereas at 4.5 MB every size that works + * now still works and only the genuinely-too-large are rejected early. Oversized images in + * that band still surface as a Bedrock API error, exactly as they do without this entry. + * + * There is no large-file path: the only non-inline Converse source is `s3Location`, which + * takes an `s3://` URI read with the caller's IAM role, not a presigned HTTPS URL. */ - fileAttachment: { maxBytes: 3_750_000, strategy: 'inline' }, + fileAttachment: { maxBytes: 4_500_000, strategy: 'inline' }, name: 'AWS Bedrock', description: 'AWS Bedrock foundation models', defaultModel: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 49708cbe288..1cfe92a8f3b 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -1235,17 +1235,6 @@ export function prepareToolsWithUsageControl( } } -/** - * Checks if a forced tool has been used in a response and manages the tool_choice accordingly - * - * @param toolCallsResponse Array of tool calls in the response - * @param originalToolChoice The original tool_choice setting used in the request - * @param logger Logger instance to use for logging - * @param provider Optional provider ID to adjust format for specific providers - * @param forcedTools Array of all tool IDs that should be forced in sequence - * @param usedForcedTools Array of tool IDs that have already been used - * @returns Object containing tracking information and next tool choice - */ /** * Narrows the SDK's `ChatCompletionMessageToolCall` union to its function variant. * @@ -1263,6 +1252,17 @@ export function isFunctionToolCall( return 'function' in toolCall && toolCall.function != null } +/** + * Checks if a forced tool has been used in a response and manages the tool_choice accordingly + * + * @param toolCallsResponse Array of tool calls in the response + * @param originalToolChoice The original tool_choice setting used in the request + * @param logger Logger instance to use for logging + * @param provider Optional provider ID to adjust format for specific providers + * @param forcedTools Array of all tool IDs that should be forced in sequence + * @param usedForcedTools Array of tool IDs that have already been used + * @returns Object containing tracking information and next tool choice + */ export function trackForcedToolUsage( toolCallsResponse: any[] | undefined, originalToolChoice: any, @@ -1579,11 +1579,8 @@ export function checkForForcedToolUsageOpenAI( let hasUsedForcedTool = false let updatedUsedForcedTools = [...usedForcedTools] - if ( - typeof toolChoice === 'object' && - response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = response.choices[0].message.tool_calls + const toolCallsResponse = response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) + if (typeof toolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, toolChoice, From 7790d30835d770faeeb2c032002dcd34a03086d9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 17:07:05 -0700 Subject: [PATCH 05/10] fix(providers): keep every attachment that works today working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../executor/handlers/agent/agent-handler.ts | 22 ++++++++++--- apps/sim/providers/attachments.test.ts | 29 +++++++++++++---- apps/sim/providers/attachments.ts | 28 ++++++++++------ .../providers/file-attachments.server.test.ts | 17 ++++++++++ apps/sim/providers/file-attachments.server.ts | 19 +++++++---- apps/sim/providers/models.ts | 32 +++++++++++-------- 6 files changed, 107 insertions(+), 40 deletions(-) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 255141131ec..c892076250d 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -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' @@ -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) { @@ -963,16 +974,17 @@ 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)) ) if (missingFile) { - const inlineMB = (INLINE_ATTACHMENT_THRESHOLD_BYTES / (1024 * 1024)).toFixed(0) - const oversized = - Number.isFinite(missingFile.size) && missingFile.size > INLINE_ATTACHMENT_THRESHOLD_BYTES + const inlineMB = (inlineMaxBytes / (1024 * 1024)).toFixed(0) + const oversized = Number.isFinite(missingFile.size) && missingFile.size > inlineMaxBytes throw new Error( 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.` diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index 62f17509aec..600560fbe40 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -16,6 +16,7 @@ import { getProviderFileStrategy, INLINE_ATTACHMENT_THRESHOLD_BYTES, inferAttachmentMimeType, + LARGE_FILE_PATH_THRESHOLD_BYTES, prepareProviderAttachments, shouldUseLargeFilePath, } from '@/providers/attachments' @@ -288,13 +289,27 @@ describe('provider attachments', () => { describe('provider large-file capability', () => { /** - * Guards the regression where the inline cap (10 MB) sat above what the payload store could - * hold once base64 inflated it, so every 6-10 MB attachment died with "Execution memory limit - * exceeded" instead of taking the provider's large-file path. + * 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('keeps the inline cap inside the payload store ceiling once base64-encoded', () => { - const encodedBytes = Math.ceil(INLINE_ATTACHMENT_THRESHOLD_BYTES / 3) * 4 + 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', () => { @@ -316,7 +331,7 @@ describe('provider large-file capability', () => { 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) @@ -325,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) diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index f8826883e21..0ef86c773df 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -15,6 +15,7 @@ import type { UserFile } from '@/executor/types' import { getProviderFileAttachment, INLINE_ATTACHMENT_MAX_BYTES, + LARGE_FILE_PATH_THRESHOLD_BYTES, type ProviderFileAttachmentStrategy, } from '@/providers/models' import type { ProviderId } from '@/providers/types' @@ -75,13 +76,12 @@ type ProviderFormattedMessage = { [key: string]: unknown } -/** - * Files at or below this size are inlined as base64; larger files take the provider's - * large-file path. Sized to the execution payload store, not to any provider — see - * {@link INLINE_ATTACHMENT_MAX_BYTES}. - */ +/** Largest file that can be carried as inline base64 when no upload path is available. */ export const INLINE_ATTACHMENT_THRESHOLD_BYTES = INLINE_ATTACHMENT_MAX_BYTES +/** Re-exported so callers choosing a hydration cap do not reach into `models.ts` directly. */ +export { LARGE_FILE_PATH_THRESHOLD_BYTES } + export type ProviderFileStrategy = ProviderFileAttachmentStrategy /** Large-file delivery strategy for a provider, sourced from its `models.ts` definition. */ @@ -90,9 +90,17 @@ export function getProviderFileStrategy(providerId: ProviderId | string): Provid } /** - * True when an oversized file has a safe provider path. Remote URLs point at the - * primary storage object, so source-backed documents can only use artifact-aware - * Files API uploads. + * True when a file should be delivered through the provider's large-file path rather than as + * inline base64. + * + * The two strategies cross over at different sizes on purpose. `files-api` carries every type + * this provider already accepts, so it takes over as soon as base64 stops being cacheable. A + * `remote-url` provider only fetches images and PDFs, so switching early would start rejecting + * text documents that inline fine today; it therefore only takes over once inlining is no longer + * possible at all. + * + * Remote URLs point at the primary storage object, so source-backed generated documents can only + * use artifact-aware Files API uploads. */ export function shouldUseLargeFilePath( file: Pick, @@ -101,7 +109,9 @@ export function shouldUseLargeFilePath( const strategy = getProviderFileAttachment(providerId).strategy if (strategy === 'inline') return false if (strategy === 'remote-url' && isGeneratedDocumentSourceType(file.type)) return false - return Number.isFinite(file.size) && file.size > INLINE_ATTACHMENT_THRESHOLD_BYTES + const threshold = + strategy === 'files-api' ? LARGE_FILE_PATH_THRESHOLD_BYTES : INLINE_ATTACHMENT_THRESHOLD_BYTES + return Number.isFinite(file.size) && file.size > threshold } const PDF_MIME_TYPE = 'application/pdf' diff --git a/apps/sim/providers/file-attachments.server.test.ts b/apps/sim/providers/file-attachments.server.test.ts index 33baee741cd..9209193a38f 100644 --- a/apps/sim/providers/file-attachments.server.test.ts +++ b/apps/sim/providers/file-attachments.server.test.ts @@ -128,6 +128,23 @@ describe('OpenAI large-file attachment lifecycle', () => { expect(request.messages?.[0].files?.[0].remoteUrl).toBeUndefined() }) + /** + * Local and disk-backed deployments have no cloud storage, so the upload path cannot read the + * bytes back. These files inline as base64 today and must keep doing so rather than hard-fail. + */ + it('leaves the file for the inline path when cloud storage is unavailable', async () => { + mockHasCloudStorage.mockReturnValue(false) + const request = makeRequest(CSV_BYTES) + + await attachLargeFileRemoteUrls(request, 'openai') + await uploadLargeFilesToProvider(request, 'openai') + + expect(fetch).not.toHaveBeenCalled() + const file = request.messages?.[0].files?.[0] + expect(file?.remoteUrl).toBeUndefined() + expect(file?.providerFileId).toBeUndefined() + }) + it('rejects a request whose attachments together exceed the combined ceiling', async () => { const request = makeRequest(30 * 1024 * 1024) const [first] = request.messages?.[0].files ?? [] diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 7293e1c131a..92aea5da590 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -33,12 +33,21 @@ function* iterateRequestFiles(messages: Message[] | undefined): Generator = { }, groq: { id: 'groq', - /** "Maximum allowed size for a request containing an image URL as input is 20MB." */ - fileAttachment: { - maxBytes: 20_000_000, - perRequestMaxBytes: 20_000_000, - strategy: 'remote-url', - }, + /** + * Left at the pre-existing ceiling: Groq's published "20MB" governs a request carrying an + * image URL, and on this path the request body holds only the URL, so it cannot bind on the + * files this guards. Groq documents no ceiling on the image it fetches, so both tightening + * the per-file cap and summing raw bytes against the request cap would reject uploads that + * work today on no documented basis. + */ + 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', From ade82d4dc3b0d4ab5b0893de6b1fe310be5bbe93 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 17:30:40 -0700 Subject: [PATCH 06/10] fix(providers): drop the provider ceiling changes and close the audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/api/knowledge/search/route.test.ts | 3 +- .../providers/baseten/models/route.test.ts | 3 +- .../ollama-cloud/models/route.test.ts | 3 +- .../providers/together/models/route.test.ts | 3 +- apps/sim/blocks/utils.test.ts | 3 +- .../utils/permission-check.test.ts | 3 +- .../handlers/agent/agent-handler.test.ts | 3 +- .../executor/handlers/agent/agent-handler.ts | 19 ++----- apps/sim/executor/handlers/pi/keys.test.ts | 3 +- .../executor/handlers/pi/pi-handler.test.ts | 3 +- apps/sim/lib/api-key/byok.test.ts | 3 +- .../workflow/edit-workflow/validation.test.ts | 3 +- apps/sim/lib/model-router/resolve.test.ts | 3 +- .../anthropic/streaming-tool-loop.test.ts | 3 +- apps/sim/providers/attachments.test.ts | 3 +- apps/sim/providers/attachments.ts | 16 ++---- apps/sim/providers/azure-openai/index.test.ts | 3 +- apps/sim/providers/baseten/index.test.ts | 3 +- apps/sim/providers/bedrock/index.test.ts | 3 +- .../bedrock/streaming-tool-loop.test.ts | 3 +- apps/sim/providers/deepseek/index.test.ts | 3 +- apps/sim/providers/deepseek/index.ts | 12 ++--- .../providers/file-attachments.server.test.ts | 16 ++---- apps/sim/providers/file-attachments.server.ts | 50 +++++++------------ apps/sim/providers/fireworks/index.test.ts | 3 +- apps/sim/providers/gemini/core.ts | 35 ++++++++----- .../gemini/streaming-tool-loop.test.ts | 3 +- apps/sim/providers/groq/index.test.ts | 3 +- apps/sim/providers/kimi/index.ts | 12 ++--- apps/sim/providers/litellm/index.test.ts | 3 +- apps/sim/providers/litellm/index.ts | 6 ++- apps/sim/providers/mistral/index.test.ts | 3 +- apps/sim/providers/mistral/index.ts | 6 ++- apps/sim/providers/models.ts | 37 +------------- apps/sim/providers/nvidia/index.ts | 12 ++--- apps/sim/providers/ollama-cloud/index.test.ts | 3 +- apps/sim/providers/ollama/index.test.ts | 3 +- .../openai-compat/streaming-tool-loop.test.ts | 3 +- .../providers/openai/core.cache-key.test.ts | 3 +- .../providers/openai/core.reasoning.test.ts | 3 +- apps/sim/providers/openrouter/index.test.ts | 3 +- apps/sim/providers/sakana/index.ts | 12 ++--- .../providers/settled-tool-streams.test.ts | 3 +- apps/sim/providers/together/index.test.ts | 3 +- apps/sim/providers/trace-enrichment.ts | 24 +++++---- apps/sim/providers/utils.ts | 18 +++++-- apps/sim/providers/vllm/index.test.ts | 3 +- 47 files changed, 169 insertions(+), 205 deletions(-) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index ae23c7e6b96..6ba0f07e0e7 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -44,7 +44,8 @@ vi.mock('@/lib/tokenization/estimators', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn().mockReturnValue({ input: 0.00001042, output: 0, diff --git a/apps/sim/app/api/providers/baseten/models/route.test.ts b/apps/sim/app/api/providers/baseten/models/route.test.ts index d65dfa1002a..ee54640b720 100644 --- a/apps/sim/app/api/providers/baseten/models/route.test.ts +++ b/apps/sim/app/api/providers/baseten/models/route.test.ts @@ -17,7 +17,8 @@ const { })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts index f71268b4ab1..0645e375dcc 100644 --- a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts @@ -19,7 +19,8 @@ const { })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/app/api/providers/together/models/route.test.ts b/apps/sim/app/api/providers/together/models/route.test.ts index 7f48a9d8e19..715c55fdea3 100644 --- a/apps/sim/app/api/providers/together/models/route.test.ts +++ b/apps/sim/app/api/providers/together/models/route.test.ts @@ -19,7 +19,8 @@ const { })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 1bab16d2d57..d6f44c9518e 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -45,7 +45,8 @@ vi.mock('@/providers/models', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, getProviderFromModel: vi.fn(() => 'openai'), })) diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 1a04a2acabd..e993a728b83 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -66,7 +66,8 @@ vi.mock('@/lib/permission-groups/types', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, getProviderFromModel: mockGetProviderFromModel, })) diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 69679abd9af..0c8486c5e95 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -30,7 +30,8 @@ import { executeTool } from '@/tools' process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, getProviderFromModel: vi.fn().mockReturnValue('mock-provider'), transformBlockTool: vi.fn(), getBaseModelProviders: vi.fn().mockReturnValue({ openai: {}, anthropic: {} }), diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index c892076250d..49d9f6a5fc8 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -52,13 +52,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 { shouldUseLargeFilePath, supportsFileAttachments } from '@/providers/attachments' import { - INLINE_ATTACHMENT_THRESHOLD_BYTES, - LARGE_FILE_PATH_THRESHOLD_BYTES, - shouldUseLargeFilePath, - supportsFileAttachments, -} from '@/providers/attachments' -import { canUseProviderLargeFilePath } from '@/providers/file-attachments.server' + 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' @@ -948,14 +946,7 @@ 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 + const inlineMaxBytes = getInlineHydrationMaxBytes(providerId) for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { const message = messages[messageIndex] diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 34d92aafad7..25278a97595 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -18,7 +18,8 @@ vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: mockGetBYOKKey, })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: mockCalculateCost, shouldBillModelUsage: mockShouldBill, })) diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index 550b707d1b0..a86b6aa43a1 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -77,7 +77,8 @@ vi.mock('@/providers/pi-providers', () => ({ resolvePiModelId: mockResolvePiModelId, })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, getProviderFromModel: mockGetProviderFromModel, })) vi.mock('@/blocks/utils', () => ({ diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index b7901e21cf5..72fc9b3ccd2 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -42,7 +42,8 @@ vi.mock('@/providers/models', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, PROVIDER_PLACEHOLDER_KEY: 'placeholder', })) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 35c39df1274..fb1771ca909 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -243,7 +243,8 @@ vi.mock('@/lib/workflows/skills/operations', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, getHostedModels: mockGetHostedModels, })) diff --git a/apps/sim/lib/model-router/resolve.test.ts b/apps/sim/lib/model-router/resolve.test.ts index 90008a72b75..3ad00a7ecc9 100644 --- a/apps/sim/lib/model-router/resolve.test.ts +++ b/apps/sim/lib/model-router/resolve.test.ts @@ -37,7 +37,8 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, getProviderFromModel: mockGetProviderFromModel, })) diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts index da87c8d6130..40c5cb30936 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -24,7 +24,8 @@ vi.mock('@/tools', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, prepareToolExecution: mockPrepareToolExecution, calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }), sumToolCosts: () => 0, diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index 600560fbe40..d45d9a80e7d 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -324,9 +324,8 @@ 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', () => { diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index 0ef86c773df..ad7ed80a00f 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -205,24 +205,14 @@ export function supportsFileAttachments(providerId: ProviderId | string): boolea /** * 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}). + * path, else the inline base64 threshold. Used for UI limits and validation. It is not the + * base64 hydration cap: that is chosen per request, because it depends on whether an upload + * path is actually reachable — see the agent handler's `inlineMaxBytes`. */ export function getProviderAttachmentMaxBytes(providerId: ProviderId | string): number { return getProviderFileAttachment(providerId).maxBytes } -/** - * Combined attachment ceiling for one request, or `null` when the provider documents none. - * Separate from {@link getProviderAttachmentMaxBytes}: a provider can accept a 50MB file yet - * still reject three 20MB files in the same call. - */ -export function getProviderRequestAttachmentMaxBytes( - providerId: ProviderId | string -): number | null { - return getProviderFileAttachment(providerId).perRequestMaxBytes ?? null -} - export function inferAttachmentMimeType(file: UserFile): string { const explicitType = file.type?.trim().toLowerCase() return resolveFileType({ diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index 6f1e1a2edb5..fac3765270a 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -76,7 +76,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ enrichLastModelSegmentFromChatCompletions: vi.fn(), })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareTools, diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index 669e6886d97..fc28c1a39a5 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -52,7 +52,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index f2cf67235c8..471682c9ac3 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -38,7 +38,8 @@ vi.mock('@/providers/models', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0, pricing: null }), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts index 61dc3970856..f69d6c109ea 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -27,7 +27,8 @@ vi.mock('@/tools', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, prepareToolExecution: vi.fn(() => ({ toolParams: { url: 'https://example.com' }, executionParams: { url: 'https://example.com' }, diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts index c9e7ee99e10..9ea041089a3 100644 --- a/apps/sim/providers/deepseek/index.test.ts +++ b/apps/sim/providers/deepseek/index.test.ts @@ -47,7 +47,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index e2e5b3b752f..9595656d68c 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -306,11 +306,9 @@ export const deepseekProvider: ProviderConfig = { }, ] - if ( - typeof originalToolChoice === 'object' && + const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls + if (typeof originalToolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, originalToolChoice, @@ -524,11 +522,9 @@ export const deepseekProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - if ( - typeof nextPayload.tool_choice === 'object' && + const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls + if (typeof nextPayload.tool_choice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, nextPayload.tool_choice, diff --git a/apps/sim/providers/file-attachments.server.test.ts b/apps/sim/providers/file-attachments.server.test.ts index 9209193a38f..64b597bc8c4 100644 --- a/apps/sim/providers/file-attachments.server.test.ts +++ b/apps/sim/providers/file-attachments.server.test.ts @@ -117,8 +117,9 @@ describe('OpenAI large-file attachment lifecycle', () => { ]) }) - it('leaves files at or below the inline cap on the base64 path', async () => { - const request = makeRequest(5 * 1024 * 1024) + /** Just under the crossover, so this fails if the threshold is ever raised back above it. */ + it('leaves files below the upload crossover on the base64 path', async () => { + const request = makeRequest(6 * 1024 * 1024) await attachLargeFileRemoteUrls(request, 'openai') await uploadLargeFilesToProvider(request, 'openai') @@ -144,15 +145,4 @@ describe('OpenAI large-file attachment lifecycle', () => { expect(file?.remoteUrl).toBeUndefined() expect(file?.providerFileId).toBeUndefined() }) - - it('rejects a request whose attachments together exceed the combined ceiling', async () => { - const request = makeRequest(30 * 1024 * 1024) - const [first] = request.messages?.[0].files ?? [] - request.messages?.[0].files?.push({ ...first, id: 'file-2', key: `${first.key}-2` }) - - await expect(attachLargeFileRemoteUrls(request, 'openai')).rejects.toThrow( - /total 60.00MB, which exceeds the 48MB combined attachment limit/ - ) - expect(fetch).not.toHaveBeenCalled() - }) }) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 92aea5da590..06c7d42dbd8 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -10,8 +10,9 @@ import type { UserFile } from '@/executor/types' import { getProviderAttachmentMaxBytes, getProviderFileStrategy, - getProviderRequestAttachmentMaxBytes, + INLINE_ATTACHMENT_THRESHOLD_BYTES, inferAttachmentMimeType, + LARGE_FILE_PATH_THRESHOLD_BYTES, shouldUseLargeFilePath, } from '@/providers/attachments' import type { Message, ProviderId, ProviderRequest } from '@/providers/types' @@ -33,6 +34,21 @@ function* iterateRequestFiles(messages: Message[] | undefined): Generator perRequestMaxBytes) { - const totalMB = (totalBytes / (1024 * 1024)).toFixed(2) - const maxMB = (perRequestMaxBytes / (1024 * 1024)).toFixed(0) - throw new Error( - `The ${fileCount} attachments in this request total ${totalMB}MB, which exceeds the ${maxMB}MB combined attachment limit for provider "${providerId}". Remove or shrink some files.` - ) - } -} - /** * For `files-api` providers, uploads each large attachment (already carrying a signed * `remoteUrl` from {@link attachLargeFileRemoteUrls}) to the provider Files API and records diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index 3a7514d5b56..5e1f9b57209 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -55,7 +55,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 2e75a048473..1a81099d521 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -582,13 +582,13 @@ function buildDeepResearchResponse( * Creates a ReadableStream from a deep research streaming interaction. * * Deep research streaming returns InteractionSSEEvent chunks including: - * - interaction.start: initial interaction with ID - * - content.delta: incremental text and thought_summary updates - * - content.start / content.stop: output boundaries - * - interaction.complete: final event (outputs is undefined in streaming; must reconstruct) + * - interaction.created: initial interaction with ID + * - step.delta: incremental text updates + * - step.start / step.stop: step boundaries + * - interaction.completed: final event (steps is undefined in streaming; must reconstruct) * - error: error events * - * We stream text deltas to the client and track usage from the interaction.complete event. + * We stream text deltas to the client and track usage from the interaction.completed event. */ function createDeepResearchStream( stream: AsyncIterable, @@ -719,16 +719,16 @@ export async function executeDeepResearchRequest( // Streaming mode: create a streaming interaction and return a StreamingExecution if (request.stream) { /** - * `stream` is annotated inline rather than via - * `Interactions.CreateAgentInteractionParamsStreaming`: as of @google/genai 2.13.0 that - * namespace alias resolves to `CreateAgentInteraction`, whose `stream` is a plain - * `boolean`. Annotating with it loses the literal that discriminates `interactions.create`'s - * overloads, so the call falls through to the union-returning signature. + * `satisfies`, not an annotation: as of @google/genai 2.13.0 the namespace alias resolves + * to `CreateAgentInteraction`, whose `stream` is a plain `boolean`, so annotating erases + * the literal that discriminates `interactions.create`'s overloads and the call falls + * through to the union-returning signature. `satisfies` keeps the literal while still + * rejecting a misspelled or unknown field. */ const streamParams = { ...baseParams, stream: true as const, - } + } satisfies Interactions.CreateAgentInteractionParamsStreaming const streamResponse = await ai.interactions.create( streamParams, @@ -808,11 +808,11 @@ export async function executeDeepResearchRequest( } // Non-streaming mode: create and poll - /** Inline literal for the same overload-discrimination reason as `streamParams` above. */ + /** `satisfies` for the same overload-discrimination reason as `streamParams` above. */ const createParams = { ...baseParams, stream: false as const, - } + } satisfies Interactions.CreateAgentInteractionParamsNonStreaming const interaction = await ai.interactions.create( createParams, @@ -839,6 +839,15 @@ export async function executeDeepResearchRequest( throw new Error(`Deep research interaction was cancelled: ${interactionId}`) } + /** + * Interactions v2 added terminal statuses beyond failed/cancelled. Without this they are + * polled until the hour-long ceiling and then reported as a Sim timeout, hiding a cause + * the caller can act on — `budget_exceeded` most of all. + */ + if (result.status === 'budget_exceeded' || result.status === 'incomplete') { + throw new Error(`Deep research interaction ended as "${result.status}": ${interactionId}`) + } + logger.info('Deep research in progress, polling...', { interactionId, status: result.status, diff --git a/apps/sim/providers/gemini/streaming-tool-loop.test.ts b/apps/sim/providers/gemini/streaming-tool-loop.test.ts index 0b14ef395d0..b2dbd668044 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.test.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.test.ts @@ -28,7 +28,8 @@ vi.mock('@/tools', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, prepareToolExecution: vi.fn(() => ({ toolParams: { url: 'https://httpbin.org/get' }, executionParams: { url: 'https://httpbin.org/get' }, diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts index 6c64f4fe25f..011cf9ea102 100644 --- a/apps/sim/providers/groq/index.test.ts +++ b/apps/sim/providers/groq/index.test.ts @@ -47,7 +47,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index 16c7839ac35..9adadada5d4 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -274,11 +274,9 @@ export const kimiProvider: ProviderConfig = { }, ] - if ( - typeof originalToolChoice === 'object' && + const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls + if (typeof originalToolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, originalToolChoice, @@ -465,11 +463,9 @@ export const kimiProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - if ( - typeof nextPayload.tool_choice === 'object' && + const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls + if (typeof nextPayload.tool_choice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, nextPayload.tool_choice, diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index af7b7fc6227..1b2b275c6a5 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -55,7 +55,8 @@ vi.mock('@/providers/litellm/utils', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), sumToolCosts: vi.fn(() => 0), prepareToolExecution: vi.fn((_tool, toolArgs) => ({ diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index fae459ec513..8000cad1c6e 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -269,8 +269,10 @@ export const litellmProvider: ProviderConfig = { toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } ) => { const toolCallsResponse = - response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - if (typeof toolChoice === 'object' && toolCallsResponse?.length) { + typeof toolChoice === 'object' + ? response.choices?.[0]?.message?.tool_calls?.filter(isFunctionToolCall) + : undefined + if (toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, toolChoice, diff --git a/apps/sim/providers/mistral/index.test.ts b/apps/sim/providers/mistral/index.test.ts index a46e58d6a48..26e88132d6e 100644 --- a/apps/sim/providers/mistral/index.test.ts +++ b/apps/sim/providers/mistral/index.test.ts @@ -36,7 +36,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ enrichLastModelSegmentFromChatCompletions: vi.fn(), })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: vi.fn((tools) => ({ diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 0bf00af2505..c16731da64c 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -204,8 +204,10 @@ export const mistralProvider: ProviderConfig = { toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } ) => { const toolCallsResponse = - response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - if (typeof toolChoice === 'object' && toolCallsResponse?.length) { + typeof toolChoice === 'object' + ? response.choices?.[0]?.message?.tool_calls?.filter(isFunctionToolCall) + : undefined + if (toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, toolChoice, diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 64e789dd706..cb46b3ec0dc 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -134,12 +134,6 @@ export type ProviderFileAttachmentStrategy = 'inline' | 'files-api' | 'remote-ur export interface ProviderFileAttachment { /** Maximum size of a single attachment the provider accepts, in bytes. */ maxBytes: number - /** - * Combined ceiling across every attachment in one request, when the provider documents one - * separately from {@link maxBytes} (OpenAI, for example, caps a request at 50 MB total no - * matter how the files divide it). Omitted when the provider documents no combined limit. - */ - perRequestMaxBytes?: number strategy: ProviderFileAttachmentStrategy } @@ -171,15 +165,6 @@ export function getProviderFileAttachment(providerId: string): ProviderFileAttac export const PROVIDER_DEFINITIONS: Record = { fireworks: { id: 'fireworks', - /** - * "Total base64-encoded images must be less than 10MB" — a budget on the encoded bytes, so - * the raw-byte equivalent this check sums is three quarters of it. - */ - fileAttachment: { - maxBytes: INLINE_ATTACHMENT_MAX_BYTES, - perRequestMaxBytes: 7_500_000, - strategy: 'inline', - }, name: 'Fireworks', description: 'Fast inference for open-source models via Fireworks AI', defaultModel: '', @@ -329,8 +314,8 @@ export const PROVIDER_DEFINITIONS: Record = { }, openai: { id: 'openai', - /** "each file must be under 50 MB. The combined limit across all files in the request is 50 MB." */ - fileAttachment: { maxBytes: 50_000_000, perRequestMaxBytes: 50_000_000, strategy: 'files-api' }, + /** "each file must be under 50 MB" — decimal MB; OpenAI writes no MiB anywhere on that page. */ + fileAttachment: { maxBytes: 50_000_000, strategy: 'files-api' }, name: 'OpenAI', description: "OpenAI's models", defaultModel: 'gpt-4.1', @@ -2447,13 +2432,6 @@ export const PROVIDER_DEFINITIONS: Record = { }, groq: { id: 'groq', - /** - * Left at the pre-existing ceiling: Groq's published "20MB" governs a request carrying an - * image URL, and on this path the request body holds only the URL, so it cannot bind on the - * files this guards. Groq documents no ceiling on the image it fetches, so both tightening - * the per-file cap and summing raw bytes against the request cap would reject uploads that - * work today on no documented basis. - */ fileAttachment: { maxBytes: 20 * 1024 * 1024, strategy: 'remote-url' }, name: 'Groq', description: "Groq's LLM models with high-performance inference", @@ -3511,17 +3489,6 @@ export const PROVIDER_DEFINITIONS: Record = { }, bedrock: { id: 'bedrock', - /** - * Converse caps an image at 3.75 MB and a document at 4.5 MB. A single `maxBytes` cannot - * express both, so it carries the higher (document) bound: clamping to 3.75 MB would reject - * 3.75-4.5 MB documents that Converse accepts today, whereas at 4.5 MB every size that works - * now still works and only the genuinely-too-large are rejected early. Oversized images in - * that band still surface as a Bedrock API error, exactly as they do without this entry. - * - * There is no large-file path: the only non-inline Converse source is `s3Location`, which - * takes an `s3://` URI read with the caller's IAM role, not a presigned HTTPS URL. - */ - fileAttachment: { maxBytes: 4_500_000, strategy: 'inline' }, name: 'AWS Bedrock', description: 'AWS Bedrock foundation models', defaultModel: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index 82c389d1e4a..d9a6335026f 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -225,11 +225,9 @@ export const nvidiaProvider: ProviderConfig = { }, ] - if ( - typeof originalToolChoice === 'object' && + const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls + if (typeof originalToolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, originalToolChoice, @@ -416,11 +414,9 @@ export const nvidiaProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - if ( - typeof nextPayload.tool_choice === 'object' && + const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls + if (typeof nextPayload.tool_choice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, nextPayload.tool_choice, diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index 67aba8ba6d9..6dd81d7d6fd 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -72,7 +72,8 @@ vi.mock('@/providers/ollama-cloud/utils', () => ({ }, })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }), generateSchemaInstructions: () => 'SCHEMA_INSTRUCTIONS', prepareToolExecution: (_tool: unknown, args: Record) => ({ diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index 1447fa42d53..8566a4d4438 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -59,7 +59,8 @@ vi.mock('@/providers/ollama/utils', () => ({ }, })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }), generateSchemaInstructions: () => 'SCHEMA_INSTRUCTIONS', prepareToolExecution: (_tool: unknown, args: Record) => ({ diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts index 7164c388dff..5c1ce68f744 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -24,7 +24,8 @@ vi.mock('@/tools', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, prepareToolExecution: mockPrepareToolExecution, calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }), sumToolCosts: () => 0, diff --git a/apps/sim/providers/openai/core.cache-key.test.ts b/apps/sim/providers/openai/core.cache-key.test.ts index 829383ccf6b..f684cb88c61 100644 --- a/apps/sim/providers/openai/core.cache-key.test.ts +++ b/apps/sim/providers/openai/core.cache-key.test.ts @@ -13,7 +13,8 @@ import type { ProviderRequest } from '@/providers/types' vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: () => ({ input: 0, output: 0, total: 0 }), sumToolCosts: () => 0, enforceStrictSchema: (schema: unknown) => schema, diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts index 5048fbd88aa..5b86df2e1fd 100644 --- a/apps/sim/providers/openai/core.reasoning.test.ts +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -15,7 +15,8 @@ import { executeTool } from '@/tools' vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: () => ({ input: 0, output: 0, total: 0 }), sumToolCosts: () => 0, enforceStrictSchema: (schema: unknown) => schema, diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index 9b11c16fa4a..93f9017b133 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -60,7 +60,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolsWithUsageControl: mockPrepareTools, prepareToolExecution: vi.fn((_tool: unknown, toolArgs: Record) => ({ diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 498298d5a08..d7273c28401 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -222,11 +222,9 @@ export const sakanaProvider: ProviderConfig = { }, ] - if ( - typeof originalToolChoice === 'object' && + const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls + if (typeof originalToolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, originalToolChoice, @@ -419,11 +417,9 @@ export const sakanaProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - if ( - typeof nextPayload.tool_choice === 'object' && + const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls + if (typeof nextPayload.tool_choice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, nextPayload.tool_choice, diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts index 0464829693f..39dce90fcd4 100644 --- a/apps/sim/providers/settled-tool-streams.test.ts +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -69,7 +69,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn(() => ({ input: 1, output: 2, total: 3 })), enforceStrictSchema: vi.fn((schema) => schema), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index e03712efd87..60e5215c809 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -52,7 +52,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts index 5ffbf5ea1c6..674f52fcb36 100644 --- a/apps/sim/providers/trace-enrichment.ts +++ b/apps/sim/providers/trace-enrichment.ts @@ -16,7 +16,9 @@ interface ChatCompletionLike { choices: Array<{ message?: { content?: string | null - tool_calls?: Array | null + /** Loose on purpose — the raw SDK response is passed here; only the separate + * `toolCallsInResponse` argument is required to be narrowed. */ + tool_calls?: Array<{ id: string; function?: { name: string; arguments: string } }> | null reasoning_content?: string | null reasoning?: string | null reasoning_details?: OpenRouterReasoningDetail[] | null @@ -34,14 +36,16 @@ interface ChatCompletionLike { } | null } +/** + * `function` stays required on purpose. The SDK's `ChatCompletionMessageToolCall` union gained a + * `custom` variant with no `function` in v5, and callers narrow that away with + * `isFunctionToolCall` before enriching. Making this optional to accept the raw union would let + * the custom shape satisfy this interface structurally, and every enrich call site would then + * type-check whether or not it narrowed — silently turning the guard into unenforced convention. + */ interface ChatCompletionToolCallLike { id: string - /** - * Absent on the `custom` tool-call variant the SDK's `ChatCompletionMessageToolCall` union - * gained in v5. Sim only ever declares function tools, so a custom call should never arrive — - * but the response type permits one, and a trace enricher must not throw on it. - */ - function?: { name: string; arguments: string } + function: { name: string; arguments: string } } /** @@ -115,7 +119,7 @@ export function enrichLastModelSegment( * Parses a tool call's `function.arguments` JSON string into an object, or * returns the raw string if it is not valid JSON. */ -function parseToolCallArguments(rawArguments = ''): Record | string { +function parseToolCallArguments(rawArguments: string): Record | string { try { const parsed = JSON.parse(rawArguments) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { @@ -185,8 +189,8 @@ export function enrichLastModelSegmentFromChatCompletions( const toolCalls: IterationToolCall[] = (toolCallsInResponse ?? []).map((tc) => ({ id: tc.id, - name: tc.function?.name ?? '', - arguments: parseToolCallArguments(tc.function?.arguments), + name: tc.function.name, + arguments: parseToolCallArguments(tc.function.arguments), })) const usage = response.usage diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1cfe92a8f3b..2ad2bb18cd1 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -1244,12 +1244,19 @@ export function prepareToolsWithUsageControl( * * Deliberately tests for the `function` payload rather than `type === 'function'`: many * OpenAI-compatible vendors omit `type` on tool calls entirely, and discriminating on it would - * silently drop every tool call those providers return. + * silently drop every tool call those providers return. Total by construction, because these + * same gateways are the ones that emit a malformed `tool_calls` entry, and this now runs on + * every tool-bearing response. */ export function isFunctionToolCall( toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall ): toolCall is OpenAI.Chat.Completions.ChatCompletionMessageFunctionToolCall { - return 'function' in toolCall && toolCall.function != null + return ( + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + toolCall.function != null + ) } /** @@ -1579,8 +1586,11 @@ export function checkForForcedToolUsageOpenAI( let hasUsedForcedTool = false let updatedUsedForcedTools = [...usedForcedTools] - const toolCallsResponse = response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - if (typeof toolChoice === 'object' && toolCallsResponse?.length) { + const toolCallsResponse = + typeof toolChoice === 'object' + ? response.choices?.[0]?.message?.tool_calls?.filter(isFunctionToolCall) + : undefined + if (toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, toolChoice, diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index 3e9019007f7..efad3d2051b 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -60,7 +60,8 @@ vi.mock('@/providers/trace-enrichment', () => ({ enrichLastModelSegmentFromChatCompletions: vi.fn(), })) vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: { function?: unknown }) => toolCall?.function != null, + isFunctionToolCall: (toolCall: unknown) => + typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareTools, From 092ac949831f124b7d108d47102482fcdad9d091 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 17:45:47 -0700 Subject: [PATCH 07/10] fix(providers): report attachment limits in the unit vendors publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sim/executor/handlers/agent/agent-handler.ts | 10 +++++++--- apps/sim/providers/attachments.test.ts | 10 ++++++++++ apps/sim/providers/attachments.ts | 16 ++++++++++++++-- apps/sim/providers/file-attachments.server.ts | 5 +++-- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 49d9f6a5fc8..f5d17c1d5f0 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -52,7 +52,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 { shouldUseLargeFilePath, supportsFileAttachments } from '@/providers/attachments' +import { + formatAttachmentBytes, + shouldUseLargeFilePath, + supportsFileAttachments, +} from '@/providers/attachments' import { canUseProviderLargeFilePath, getInlineHydrationMaxBytes, @@ -974,11 +978,11 @@ export class AgentBlockHandler implements BlockHandler { !(canUseProviderLargeFilePath(providerId) && shouldUseLargeFilePath(file, providerId)) ) if (missingFile) { - const inlineMB = (inlineMaxBytes / (1024 * 1024)).toFixed(0) + const inlineMB = formatAttachmentBytes(inlineMaxBytes) const oversized = Number.isFinite(missingFile.size) && missingFile.size > inlineMaxBytes throw new Error( 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}" (${formatAttachmentBytes(missingFile.size)}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.` ) } diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index d45d9a80e7d..e762534dc83 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -11,6 +11,7 @@ import { buildOpenAICompatibleChatContent, buildOpenAIMessageContent, buildOpenRouterMessageContent, + formatAttachmentBytes, formatMessagesForProvider, getProviderAttachmentMaxBytes, getProviderFileStrategy, @@ -287,6 +288,15 @@ describe('provider attachments', () => { }) }) +describe('attachment limit formatting', () => { + /** Guards the report of OpenAI's decimal 50 MB ceiling as "48MB" when divided by 1024². */ + it('reports a decimal-MB ceiling as the vendor publishes it', () => { + expect(formatAttachmentBytes(50_000_000)).toBe('50') + expect(formatAttachmentBytes(10 * 1024 * 1024)).toBe('10') + expect(formatAttachmentBytes(9_591_617)).toBe('9.59') + }) +}) + describe('provider large-file capability', () => { /** * Guards the regression where every 6-10 MB attachment died with "Execution memory limit diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index ad7ed80a00f..d3cc24662d4 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -213,6 +213,18 @@ export function getProviderAttachmentMaxBytes(providerId: ProviderId | string): return getProviderFileAttachment(providerId).maxBytes } +/** + * Renders a byte count for a user-facing limit message. + * + * Decimal MB, because that is the unit the vendors publish and therefore the number a user is + * comparing against. Dividing by 1024² instead reported OpenAI's 50 MB ceiling as "48MB", so a + * user shrinking a 49 MB file to get under it was chasing a limit that did not exist. + */ +export function formatAttachmentBytes(bytes: number): string { + const megabytes = bytes / 1_000_000 + return megabytes < 10 ? megabytes.toFixed(2).replace(/\.?0+$/, '') : megabytes.toFixed(0) +} + export function inferAttachmentMimeType(file: UserFile): string { const explicitType = file.type?.trim().toLowerCase() return resolveFileType({ @@ -400,8 +412,8 @@ export function prepareProviderAttachments( const maxBytes = getProviderAttachmentMaxBytes(providerId) if (Number.isFinite(file.size) && file.size > maxBytes) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(2) - const maxMB = (maxBytes / (1024 * 1024)).toFixed(0) + const sizeMB = formatAttachmentBytes(file.size) + const maxMB = formatAttachmentBytes(maxBytes) throw new Error( `File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"` ) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 06c7d42dbd8..2550fd66d02 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -8,6 +8,7 @@ import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils. import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import { + formatAttachmentBytes, getProviderAttachmentMaxBytes, getProviderFileStrategy, INLINE_ATTACHMENT_THRESHOLD_BYTES, @@ -88,8 +89,8 @@ export async function attachLargeFileRemoteUrls( 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) + const sizeMB = formatAttachmentBytes(file.size) + const maxMB = formatAttachmentBytes(maxBytes) throw new Error( `File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"` ) From 8bb7b3ef34888c420d20399dcb7376fb7345adcf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 18:00:39 -0700 Subject: [PATCH 08/10] fix(providers): derive the limit unit from the ceiling it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/api/knowledge/search/route.test.ts | 5 ++- .../providers/baseten/models/route.test.ts | 5 ++- .../ollama-cloud/models/route.test.ts | 5 ++- .../providers/together/models/route.test.ts | 5 ++- apps/sim/blocks/utils.test.ts | 5 ++- .../utils/permission-check.test.ts | 5 ++- .../handlers/agent/agent-handler.test.ts | 5 ++- .../executor/handlers/agent/agent-handler.ts | 14 +++++-- apps/sim/executor/handlers/pi/keys.test.ts | 5 ++- .../executor/handlers/pi/pi-handler.test.ts | 5 ++- apps/sim/lib/api-key/byok.test.ts | 5 ++- .../workflow/edit-workflow/validation.test.ts | 5 ++- apps/sim/lib/model-router/resolve.test.ts | 5 ++- .../anthropic/streaming-tool-loop.test.ts | 5 ++- apps/sim/providers/attachments.test.ts | 37 +++++++++++++++--- apps/sim/providers/attachments.ts | 38 ++++++++++++++----- apps/sim/providers/azure-openai/index.test.ts | 5 ++- apps/sim/providers/baseten/index.test.ts | 5 ++- apps/sim/providers/bedrock/index.test.ts | 5 ++- .../bedrock/streaming-tool-loop.test.ts | 5 ++- apps/sim/providers/deepseek/index.test.ts | 5 ++- .../providers/file-attachments.server.test.ts | 29 ++++++++++++-- apps/sim/providers/file-attachments.server.ts | 5 +-- apps/sim/providers/fireworks/index.test.ts | 5 ++- .../gemini/streaming-tool-loop.test.ts | 5 ++- apps/sim/providers/groq/index.test.ts | 5 ++- apps/sim/providers/litellm/index.test.ts | 5 ++- apps/sim/providers/mistral/index.test.ts | 5 ++- apps/sim/providers/ollama-cloud/index.test.ts | 5 ++- apps/sim/providers/ollama/index.test.ts | 5 ++- .../openai-compat/streaming-tool-loop.test.ts | 5 ++- .../providers/openai/core.cache-key.test.ts | 5 ++- .../providers/openai/core.reasoning.test.ts | 5 ++- apps/sim/providers/openrouter/index.test.ts | 5 ++- .../providers/settled-tool-streams.test.ts | 5 ++- apps/sim/providers/together/index.test.ts | 5 ++- apps/sim/providers/trace-enrichment.ts | 8 +++- apps/sim/providers/vllm/index.test.ts | 5 ++- 38 files changed, 232 insertions(+), 59 deletions(-) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 6ba0f07e0e7..6fdf4cdfdc6 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -45,7 +45,10 @@ vi.mock('@/lib/tokenization/estimators', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn().mockReturnValue({ input: 0.00001042, output: 0, diff --git a/apps/sim/app/api/providers/baseten/models/route.test.ts b/apps/sim/app/api/providers/baseten/models/route.test.ts index ee54640b720..e3c2cf4150a 100644 --- a/apps/sim/app/api/providers/baseten/models/route.test.ts +++ b/apps/sim/app/api/providers/baseten/models/route.test.ts @@ -18,7 +18,10 @@ const { vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts index 0645e375dcc..a4b4cbfedda 100644 --- a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts @@ -20,7 +20,10 @@ const { vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/app/api/providers/together/models/route.test.ts b/apps/sim/app/api/providers/together/models/route.test.ts index 715c55fdea3..d5b9912dda1 100644 --- a/apps/sim/app/api/providers/together/models/route.test.ts +++ b/apps/sim/app/api/providers/together/models/route.test.ts @@ -20,7 +20,10 @@ const { vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, })) diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index d6f44c9518e..9cdb1d1df23 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -46,7 +46,10 @@ vi.mock('@/providers/models', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, getProviderFromModel: vi.fn(() => 'openai'), })) diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index e993a728b83..8a9d502c80c 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -67,7 +67,10 @@ vi.mock('@/lib/permission-groups/types', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, getProviderFromModel: mockGetProviderFromModel, })) diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 0c8486c5e95..0da21a6a431 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -31,7 +31,10 @@ process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + 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: {} }), diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index f5d17c1d5f0..e698d2ee995 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -53,7 +53,8 @@ import { stringifyJSON } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' import { - formatAttachmentBytes, + formatAttachmentSizes, + getProviderFileStrategy, shouldUseLargeFilePath, supportsFileAttachments, } from '@/providers/attachments' @@ -978,11 +979,18 @@ export class AgentBlockHandler implements BlockHandler { !(canUseProviderLargeFilePath(providerId) && shouldUseLargeFilePath(file, providerId)) ) if (missingFile) { - const inlineMB = formatAttachmentBytes(inlineMaxBytes) + const { size: sizeMB, limit: inlineMB } = formatAttachmentSizes( + missingFile.size, + inlineMaxBytes + ) const oversized = Number.isFinite(missingFile.size) && missingFile.size > inlineMaxBytes + const reason = + getProviderFileStrategy(providerId) === 'inline' + ? `provider "${providerId}" has no large-file upload path` + : 'this deployment has no cloud file storage for the large-file upload path' throw new Error( oversized - ? `File "${missingFile.name}" (${formatAttachmentBytes(missingFile.size)}MB) exceeds the ${inlineMB}MB inline attachment limit, and provider "${providerId}" has no large-file upload path for it.` + ? `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.` ) } diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 25278a97595..5c0181d85b4 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -19,7 +19,10 @@ vi.mock('@/lib/api-key/byok', () => ({ })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: mockCalculateCost, shouldBillModelUsage: mockShouldBill, })) diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index a86b6aa43a1..7a97fea5e45 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -78,7 +78,10 @@ vi.mock('@/providers/pi-providers', () => ({ })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, getProviderFromModel: mockGetProviderFromModel, })) vi.mock('@/blocks/utils', () => ({ diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index 72fc9b3ccd2..c5da3daabf3 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -43,7 +43,10 @@ vi.mock('@/providers/models', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, PROVIDER_PLACEHOLDER_KEY: 'placeholder', })) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index fb1771ca909..651c816f811 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -244,7 +244,10 @@ vi.mock('@/lib/workflows/skills/operations', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, getHostedModels: mockGetHostedModels, })) diff --git a/apps/sim/lib/model-router/resolve.test.ts b/apps/sim/lib/model-router/resolve.test.ts index 3ad00a7ecc9..fcb68aded77 100644 --- a/apps/sim/lib/model-router/resolve.test.ts +++ b/apps/sim/lib/model-router/resolve.test.ts @@ -38,7 +38,10 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, getProviderFromModel: mockGetProviderFromModel, })) diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts index 40c5cb30936..558402119c5 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -25,7 +25,10 @@ vi.mock('@/tools', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + 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, diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index e762534dc83..432351ec30f 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -11,7 +11,7 @@ import { buildOpenAICompatibleChatContent, buildOpenAIMessageContent, buildOpenRouterMessageContent, - formatAttachmentBytes, + formatAttachmentSizes, formatMessagesForProvider, getProviderAttachmentMaxBytes, getProviderFileStrategy, @@ -289,11 +289,28 @@ describe('provider attachments', () => { }) describe('attachment limit formatting', () => { - /** Guards the report of OpenAI's decimal 50 MB ceiling as "48MB" when divided by 1024². */ - it('reports a decimal-MB ceiling as the vendor publishes it', () => { - expect(formatAttachmentBytes(50_000_000)).toBe('50') - expect(formatAttachmentBytes(10 * 1024 * 1024)).toBe('10') - expect(formatAttachmentBytes(9_591_617)).toBe('9.59') + /** + * Guards both directions of the unit bug: dividing every ceiling by 1024² reported OpenAI's + * decimal 50 MB as "48MB", and dividing every ceiling by 10⁶ reported Anthropic's 50 MiB as + * "52MB". Each vendor's number must come back as that vendor publishes it. + */ + it('reports each ceiling in the unit its vendor publishes', () => { + expect(formatAttachmentSizes(0, 50_000_000).limit).toBe('50') + expect(formatAttachmentSizes(0, 50 * 1024 * 1024).limit).toBe('50') + expect(formatAttachmentSizes(0, 20 * 1024 * 1024).limit).toBe('20') + expect(formatAttachmentSizes(0, 25 * 1024 * 1024).limit).toBe('25') + expect(formatAttachmentSizes(0, 10 * 1024 * 1024).limit).toBe('10') + }) + + /** The size and the ceiling share a unit, so the sentence can never contradict itself. */ + it('never renders an over-limit file as equal to the limit', () => { + const groq = formatAttachmentSizes(21_000_000, 20 * 1024 * 1024) + expect(groq.limit).toBe('20') + expect(groq.size).toBe('20.03') + + const openai = formatAttachmentSizes(9_591_617, 50_000_000) + expect(openai.limit).toBe('50') + expect(openai.size).toBe('9.59') }) }) @@ -313,6 +330,14 @@ describe('provider large-file capability', () => { * 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. */ + /** A size we cannot read must still reach the uploader, which enforces the ceiling itself. */ + it('routes an unknown-size file to a files-api upload rather than stranding it', () => { + const unknown = { size: 0, type: 'text/csv' } + expect(shouldUseLargeFilePath(unknown, 'openai')).toBe(true) + expect(shouldUseLargeFilePath(unknown, 'anthropic')).toBe(false) + expect(shouldUseLargeFilePath({ size: Number.NaN, type: 'text/csv' }, 'openai')).toBe(true) + }) + 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) diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index d3cc24662d4..73f5e62bcaa 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -111,7 +111,14 @@ export function shouldUseLargeFilePath( if (strategy === 'remote-url' && isGeneratedDocumentSourceType(file.type)) return false const threshold = strategy === 'files-api' ? LARGE_FILE_PATH_THRESHOLD_BYTES : INLINE_ATTACHMENT_THRESHOLD_BYTES - return Number.isFinite(file.size) && file.size > threshold + /** + * A file whose declared size is missing or zero cannot be routed by size. `files-api` uploads + * read the real bytes from storage and enforce the ceiling there, so routing one is always + * safe — and refusing to would strand it with neither base64 (hydration bails on the real + * length) nor a handle. + */ + if (!Number.isFinite(file.size) || file.size <= 0) return strategy === 'files-api' + return file.size > threshold } const PDF_MIME_TYPE = 'application/pdf' @@ -213,16 +220,28 @@ export function getProviderAttachmentMaxBytes(providerId: ProviderId | string): return getProviderFileAttachment(providerId).maxBytes } +const MEBIBYTE = 1024 * 1024 + /** - * Renders a byte count for a user-facing limit message. + * Renders a size and the ceiling it violated, both in one unit derived from the ceiling. * - * Decimal MB, because that is the unit the vendors publish and therefore the number a user is - * comparing against. Dividing by 1024² instead reported OpenAI's 50 MB ceiling as "48MB", so a - * user shrinking a 49 MB file to get under it was chasing a limit that did not exist. + * Ceilings are authored in whichever unit the vendor publishes — decimal MB for OpenAI, binary + * MiB for everyone else — so a single fixed divisor is wrong for one group or the other: 1024² + * reports OpenAI's 50 MB as "48MB", and 10⁶ reports Anthropic's 50 MiB as "52MB". Either way the + * user is told a limit that does not exist. Taking the unit from the ceiling keeps the number + * they see equal to the number the vendor documents, and keeps both figures in the same sentence + * directly comparable. */ -export function formatAttachmentBytes(bytes: number): string { - const megabytes = bytes / 1_000_000 - return megabytes < 10 ? megabytes.toFixed(2).replace(/\.?0+$/, '') : megabytes.toFixed(0) +export function formatAttachmentSizes( + bytes: number, + limitBytes: number +): { size: string; limit: string } { + const divisor = limitBytes % MEBIBYTE === 0 ? MEBIBYTE : 1_000_000 + const render = (value: number) => { + const scaled = value / divisor + return Number.isInteger(scaled) ? String(scaled) : scaled.toFixed(2) + } + return { size: render(bytes), limit: render(limitBytes) } } export function inferAttachmentMimeType(file: UserFile): string { @@ -412,8 +431,7 @@ export function prepareProviderAttachments( const maxBytes = getProviderAttachmentMaxBytes(providerId) if (Number.isFinite(file.size) && file.size > maxBytes) { - const sizeMB = formatAttachmentBytes(file.size) - const maxMB = formatAttachmentBytes(maxBytes) + const { size: sizeMB, limit: maxMB } = formatAttachmentSizes(file.size, maxBytes) throw new Error( `File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"` ) diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index fac3765270a..f1da6cec347 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -77,7 +77,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareTools, diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index fc28c1a39a5..d0a4ed0308c 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -53,7 +53,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index 471682c9ac3..bf4b2fdecdd 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -39,7 +39,10 @@ vi.mock('@/providers/models', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0, pricing: null }), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts index f69d6c109ea..ebb828e1dea 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -28,7 +28,10 @@ vi.mock('@/tools', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, prepareToolExecution: vi.fn(() => ({ toolParams: { url: 'https://example.com' }, executionParams: { url: 'https://example.com' }, diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts index 9ea041089a3..174123c7ffa 100644 --- a/apps/sim/providers/deepseek/index.test.ts +++ b/apps/sim/providers/deepseek/index.test.ts @@ -48,7 +48,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, diff --git a/apps/sim/providers/file-attachments.server.test.ts b/apps/sim/providers/file-attachments.server.test.ts index 64b597bc8c4..ca05e9692e5 100644 --- a/apps/sim/providers/file-attachments.server.test.ts +++ b/apps/sim/providers/file-attachments.server.test.ts @@ -2,9 +2,14 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildOpenAIMessageContent } from '@/providers/attachments' +import { + buildOpenAIMessageContent, + INLINE_ATTACHMENT_THRESHOLD_BYTES, + LARGE_FILE_PATH_THRESHOLD_BYTES, +} from '@/providers/attachments' import { attachLargeFileRemoteUrls, + getInlineHydrationMaxBytes, uploadLargeFilesToProvider, } from '@/providers/file-attachments.server' import type { ProviderRequest } from '@/providers/types' @@ -117,9 +122,9 @@ describe('OpenAI large-file attachment lifecycle', () => { ]) }) - /** Just under the crossover, so this fails if the threshold is ever raised back above it. */ - it('leaves files below the upload crossover on the base64 path', async () => { - const request = makeRequest(6 * 1024 * 1024) + /** Exactly at the crossover — `shouldUseLargeFilePath` uses `>`, so this must stay inline. */ + it('leaves a file exactly at the upload crossover on the base64 path', async () => { + const request = makeRequest(LARGE_FILE_PATH_THRESHOLD_BYTES) await attachLargeFileRemoteUrls(request, 'openai') await uploadLargeFilesToProvider(request, 'openai') @@ -129,6 +134,22 @@ describe('OpenAI large-file attachment lifecycle', () => { expect(request.messages?.[0].files?.[0].remoteUrl).toBeUndefined() }) + /** + * The hydration cap has to track `shouldUseLargeFilePath`'s crossover exactly. Stopping short + * of it leaves a band with neither base64 nor a handle — the defect this function was added to + * remove — and `remote-url` deliberately crosses over later than `files-api`. + */ + it('caps base64 hydration exactly where each strategy hands off to an upload', () => { + mockHasCloudStorage.mockReturnValue(true) + expect(getInlineHydrationMaxBytes('openai')).toBe(LARGE_FILE_PATH_THRESHOLD_BYTES) + expect(getInlineHydrationMaxBytes('anthropic')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES) + expect(getInlineHydrationMaxBytes('bedrock')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES) + + mockHasCloudStorage.mockReturnValue(false) + expect(getInlineHydrationMaxBytes('openai')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES) + expect(getInlineHydrationMaxBytes('anthropic')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES) + }) + /** * Local and disk-backed deployments have no cloud storage, so the upload path cannot read the * bytes back. These files inline as base64 today and must keep doing so rather than hard-fail. diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 2550fd66d02..39fcbf9267e 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -8,7 +8,7 @@ import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils. import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import { - formatAttachmentBytes, + formatAttachmentSizes, getProviderAttachmentMaxBytes, getProviderFileStrategy, INLINE_ATTACHMENT_THRESHOLD_BYTES, @@ -89,8 +89,7 @@ export async function attachLargeFileRemoteUrls( if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue if (Number.isFinite(file.size) && file.size > maxBytes) { - const sizeMB = formatAttachmentBytes(file.size) - const maxMB = formatAttachmentBytes(maxBytes) + const { size: sizeMB, limit: maxMB } = formatAttachmentSizes(file.size, maxBytes) throw new Error( `File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"` ) diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index 5e1f9b57209..9dcd6d9cfe5 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -56,7 +56,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/gemini/streaming-tool-loop.test.ts b/apps/sim/providers/gemini/streaming-tool-loop.test.ts index b2dbd668044..3d8db0ecc6e 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.test.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.test.ts @@ -29,7 +29,10 @@ vi.mock('@/tools', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, prepareToolExecution: vi.fn(() => ({ toolParams: { url: 'https://httpbin.org/get' }, executionParams: { url: 'https://httpbin.org/get' }, diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts index 011cf9ea102..a10fdce4454 100644 --- a/apps/sim/providers/groq/index.test.ts +++ b/apps/sim/providers/groq/index.test.ts @@ -48,7 +48,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 1b2b275c6a5..09525507656 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -56,7 +56,10 @@ vi.mock('@/providers/litellm/utils', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), sumToolCosts: vi.fn(() => 0), prepareToolExecution: vi.fn((_tool, toolArgs) => ({ diff --git a/apps/sim/providers/mistral/index.test.ts b/apps/sim/providers/mistral/index.test.ts index 26e88132d6e..403759143bc 100644 --- a/apps/sim/providers/mistral/index.test.ts +++ b/apps/sim/providers/mistral/index.test.ts @@ -37,7 +37,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: vi.fn((tools) => ({ diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index 6dd81d7d6fd..fd1d56b804b 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -73,7 +73,10 @@ vi.mock('@/providers/ollama-cloud/utils', () => ({ })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }), generateSchemaInstructions: () => 'SCHEMA_INSTRUCTIONS', prepareToolExecution: (_tool: unknown, args: Record) => ({ diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index 8566a4d4438..99465b457c8 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -60,7 +60,10 @@ vi.mock('@/providers/ollama/utils', () => ({ })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }), generateSchemaInstructions: () => 'SCHEMA_INSTRUCTIONS', prepareToolExecution: (_tool: unknown, args: Record) => ({ diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts index 5c1ce68f744..651f968ab9c 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -25,7 +25,10 @@ vi.mock('@/tools', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + 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, diff --git a/apps/sim/providers/openai/core.cache-key.test.ts b/apps/sim/providers/openai/core.cache-key.test.ts index f684cb88c61..8e0c9375a58 100644 --- a/apps/sim/providers/openai/core.cache-key.test.ts +++ b/apps/sim/providers/openai/core.cache-key.test.ts @@ -14,7 +14,10 @@ vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: () => ({ input: 0, output: 0, total: 0 }), sumToolCosts: () => 0, enforceStrictSchema: (schema: unknown) => schema, diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts index 5b86df2e1fd..6c32af673ae 100644 --- a/apps/sim/providers/openai/core.reasoning.test.ts +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -16,7 +16,10 @@ vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: () => ({ input: 0, output: 0, total: 0 }), sumToolCosts: () => 0, enforceStrictSchema: (schema: unknown) => schema, diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index 93f9017b133..c51f2151af7 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -61,7 +61,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolsWithUsageControl: mockPrepareTools, prepareToolExecution: vi.fn((_tool: unknown, toolArgs: Record) => ({ diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts index 39dce90fcd4..44f4d4622ab 100644 --- a/apps/sim/providers/settled-tool-streams.test.ts +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -70,7 +70,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn(() => ({ input: 1, output: 2, total: 3 })), enforceStrictSchema: vi.fn((schema) => schema), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index 60e5215c809..c9c75846cb9 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -53,7 +53,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts index 674f52fcb36..342fd38b570 100644 --- a/apps/sim/providers/trace-enrichment.ts +++ b/apps/sim/providers/trace-enrichment.ts @@ -120,6 +120,12 @@ export function enrichLastModelSegment( * returns the raw string if it is not valid JSON. */ function parseToolCallArguments(rawArguments: string): Record | string { + /** + * `isFunctionToolCall` only proves `function` is present, not that it is well formed — a + * gateway can send `function: {}`. Without this the JSON parse below receives `undefined` and + * this returns it, breaking the declared return type. + */ + if (typeof rawArguments !== 'string') return '' try { const parsed = JSON.parse(rawArguments) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { @@ -189,7 +195,7 @@ export function enrichLastModelSegmentFromChatCompletions( const toolCalls: IterationToolCall[] = (toolCallsInResponse ?? []).map((tc) => ({ id: tc.id, - name: tc.function.name, + name: tc.function.name ?? '', arguments: parseToolCallArguments(tc.function.arguments), })) diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index efad3d2051b..85ac6e80f1c 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -61,7 +61,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall, + typeof toolCall === 'object' && + toolCall !== null && + 'function' in toolCall && + (toolCall as { function?: unknown }).function != null, calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), prepareToolsWithUsageControl: mockPrepareTools, From f1889ad2febb9bbdc3b42585d592d43837e72270 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 18:13:16 -0700 Subject: [PATCH 09/10] fix(providers): stop an over-limit size rendering as the limit itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../executor/handlers/agent/agent-handler.ts | 6 +++-- apps/sim/providers/attachments.test.ts | 26 +++++++++++++++---- apps/sim/providers/attachments.ts | 12 ++++++--- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index e698d2ee995..8dcf6346ed1 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -15,6 +15,7 @@ import { SIM_AUTO_SYSTEM_PREAMBLE, } from '@/lib/model-router/resolve' import { + isGeneratedDocumentSourceType, MODEL_SUPPORTED_IMAGE_MIME_TYPES, processFilesToUserFiles, type RawFileInput, @@ -984,8 +985,9 @@ export class AgentBlockHandler implements BlockHandler { inlineMaxBytes ) const oversized = Number.isFinite(missingFile.size) && missingFile.size > inlineMaxBytes - const reason = - getProviderFileStrategy(providerId) === 'inline' + const reason = isGeneratedDocumentSourceType(missingFile.type) + ? `a generated document cannot use the large-file path for provider "${providerId}"` + : getProviderFileStrategy(providerId) === 'inline' ? `provider "${providerId}" has no large-file upload path` : 'this deployment has no cloud file storage for the large-file upload path' throw new Error( diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index 432351ec30f..c9c1cc7ace1 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -302,15 +302,31 @@ describe('attachment limit formatting', () => { expect(formatAttachmentSizes(0, 10 * 1024 * 1024).limit).toBe('10') }) - /** The size and the ceiling share a unit, so the sentence can never contradict itself. */ + /** + * Exercises `limit + 1` for every ceiling in the registry, which is the only input that can + * expose this: rounding both figures to the nearest hundredth rendered a file one byte over a + * 20 MiB cap as "20.00MB exceeds the 20MB limit". The previous version of this test asserted + * a file 0.03MB over and an openai file *under* the limit, so it passed while that was live. + */ it('never renders an over-limit file as equal to the limit', () => { + const ceilings = [ + 50 * 1024 * 1024, + 25 * 1024 * 1024, + 20 * 1024 * 1024, + 10 * 1024 * 1024, + 6 * 1024 * 1024, + 50_000_000, + ] + for (const limit of ceilings) { + const justOver = formatAttachmentSizes(limit + 1, limit) + expect(justOver.size).not.toBe(justOver.limit) + } + }) + + it('keeps a comfortably over-limit size readable', () => { const groq = formatAttachmentSizes(21_000_000, 20 * 1024 * 1024) expect(groq.limit).toBe('20') expect(groq.size).toBe('20.03') - - const openai = formatAttachmentSizes(9_591_617, 50_000_000) - expect(openai.limit).toBe('50') - expect(openai.size).toBe('9.59') }) }) diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index 73f5e62bcaa..74b1437c676 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -237,11 +237,17 @@ export function formatAttachmentSizes( limitBytes: number ): { size: string; limit: string } { const divisor = limitBytes % MEBIBYTE === 0 ? MEBIBYTE : 1_000_000 - const render = (value: number) => { - const scaled = value / divisor + /** + * The size rounds up and the ceiling rounds down, so an over-limit file can never render as + * the same number as the limit it broke. Rounding both to the nearest hundredth instead let a + * file one byte over a 20 MiB cap print as "20.00MB exceeds the 20MB limit" — a sentence that + * tells the user to shrink to a size they are already under. + */ + const render = (value: number, round: (n: number) => number) => { + const scaled = round((value / divisor) * 100) / 100 return Number.isInteger(scaled) ? String(scaled) : scaled.toFixed(2) } - return { size: render(bytes), limit: render(limitBytes) } + return { size: render(bytes, Math.ceil), limit: render(limitBytes, Math.floor) } } export function inferAttachmentMimeType(file: UserFile): string { From 03569122b55763a4e9101e6c20b75645a69a2e4e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 18:31:10 -0700 Subject: [PATCH 10/10] fix(providers): order the attachment failure reason by how general the cause is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../executor/handlers/agent/agent-handler.ts | 17 ++++++++++++----- apps/sim/providers/attachments.test.ts | 7 +++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 8dcf6346ed1..8d510d1696e 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -15,7 +15,6 @@ import { SIM_AUTO_SYSTEM_PREAMBLE, } from '@/lib/model-router/resolve' import { - isGeneratedDocumentSourceType, MODEL_SUPPORTED_IMAGE_MIME_TYPES, processFilesToUserFiles, type RawFileInput, @@ -985,11 +984,19 @@ export class AgentBlockHandler implements BlockHandler { inlineMaxBytes ) const oversized = Number.isFinite(missingFile.size) && missingFile.size > inlineMaxBytes - const reason = isGeneratedDocumentSourceType(missingFile.type) - ? `a generated document cannot use the large-file path for provider "${providerId}"` - : getProviderFileStrategy(providerId) === 'inline' + /** + * 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` - : 'this deployment has no cloud file storage for the 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( oversized ? `File "${missingFile.name}" (${sizeMB}MB) exceeds the ${inlineMB}MB inline attachment limit, and ${reason}.` diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index c9c1cc7ace1..b9da7f426d2 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -321,6 +321,13 @@ describe('attachment limit formatting', () => { const justOver = formatAttachmentSizes(limit + 1, limit) expect(justOver.size).not.toBe(justOver.limit) } + + /** + * Every ceiling above divides to an exact integer, so floor/round/ceil are indistinguishable + * on them — only a ceiling with a fractional remainder pins the limit-side rounding. + */ + const fractional = formatAttachmentSizes(12_345_679, 12_345_678) + expect(fractional.size).not.toBe(fractional.limit) }) it('keeps a comfortably over-limit size readable', () => {