d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
262 lines
9.8 KiB
TypeScript
262 lines
9.8 KiB
TypeScript
import { FileState, GoogleGenAI } from '@google/genai'
|
|
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { sleep } from '@sim/utils/helpers'
|
|
import { StorageService } from '@/lib/uploads'
|
|
import { resolveTrustedFileContext } from '@/lib/uploads/utils/file-utils'
|
|
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
|
import { verifyFileAccess } from '@/app/api/files/authorization'
|
|
import type { UserFile } from '@/executor/types'
|
|
import {
|
|
getProviderAttachmentMaxBytes,
|
|
getProviderFileStrategy,
|
|
inferAttachmentMimeType,
|
|
shouldUseLargeFilePath,
|
|
} from '@/providers/attachments'
|
|
import type { Message, ProviderId, ProviderRequest } from '@/providers/types'
|
|
|
|
const logger = createLogger('ProviderFileAttachments')
|
|
|
|
const OPENAI_FILES_ENDPOINT = 'https://api.openai.com/v1/files'
|
|
const PRESIGNED_URL_EXPIRY_SECONDS = 60 * 60
|
|
/** OpenAI auto-deletes uploaded files after this window — see the "rely on provider expiry" lifecycle. */
|
|
const OPENAI_FILE_EXPIRY_SECONDS = 60 * 60
|
|
const GEMINI_POLL_INTERVAL_MS = 1000
|
|
const GEMINI_PROCESSING_TIMEOUT_MS = 5 * 60_000
|
|
|
|
function* iterateRequestFiles(messages: Message[] | undefined): Generator<UserFile> {
|
|
for (const message of messages ?? []) {
|
|
for (const file of message.files ?? []) {
|
|
yield file
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves every attachment that exceeds the inline threshold on a large-file-capable
|
|
* provider to a short-lived signed URL on `file.remoteUrl`. `remote-url` providers send it
|
|
* to the model directly; for `files-api` providers it marks the file for upload (the bytes
|
|
* are read from storage at upload time). Requires cloud storage — a large file (already past
|
|
* the inline base64 cap) cannot be sent without it, so the request fails with a clear error.
|
|
*
|
|
* Runs for every request in {@link executeProviderRequest} (after the API key resolves), so
|
|
* the server-only handle fields are first cleared on every file for every provider — a forged
|
|
* handle on an untrusted request body can never survive to a builder or trigger a fetch.
|
|
*/
|
|
export async function attachLargeFileRemoteUrls(
|
|
request: ProviderRequest,
|
|
providerId: ProviderId | string
|
|
): Promise<void> {
|
|
for (const file of iterateRequestFiles(request.messages)) {
|
|
file.providerFileId = undefined
|
|
file.providerFileUri = undefined
|
|
file.remoteUrl = undefined
|
|
}
|
|
|
|
if (getProviderFileStrategy(providerId) === 'inline') return
|
|
|
|
const requestId = request.workflowId ?? 'provider-request'
|
|
const maxBytes = getProviderAttachmentMaxBytes(providerId)
|
|
|
|
for (const file of iterateRequestFiles(request.messages)) {
|
|
if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue
|
|
|
|
if (Number.isFinite(file.size) && file.size > maxBytes) {
|
|
const sizeMB = (file.size / (1024 * 1024)).toFixed(2)
|
|
const maxMB = (maxBytes / (1024 * 1024)).toFixed(0)
|
|
throw new Error(
|
|
`File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"`
|
|
)
|
|
}
|
|
|
|
if (!StorageService.hasCloudStorage()) {
|
|
logger.warn(
|
|
`[${requestId}] "${file.name}" exceeds the inline limit for "${providerId}" but cloud storage is unavailable`
|
|
)
|
|
throw new Error(
|
|
`File "${file.name}" exceeds the inline attachment limit and requires cloud file storage, which is not configured`
|
|
)
|
|
}
|
|
|
|
if (!request.userId) {
|
|
throw new Error(
|
|
`File "${file.name}" requires an authenticated user for provider "${providerId}"`
|
|
)
|
|
}
|
|
|
|
const context = resolveTrustedFileContext(file.key, file.context)
|
|
const hasAccess = await verifyFileAccess(file.key, request.userId, undefined, context, false)
|
|
if (!hasAccess) {
|
|
throw new Error(`File "${file.name}" is not accessible for provider "${providerId}"`)
|
|
}
|
|
|
|
file.remoteUrl = await StorageService.generatePresignedDownloadUrl(
|
|
file.key,
|
|
context,
|
|
PRESIGNED_URL_EXPIRY_SECONDS
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For `files-api` providers, uploads each large attachment (already carrying a signed
|
|
* `remoteUrl` from {@link attachLargeFileRemoteUrls}) to the provider Files API and records
|
|
* the returned handle on the file. Runs after the request's API key is resolved so hosted
|
|
* and BYOK keys both work.
|
|
*/
|
|
export async function uploadLargeFilesToProvider(
|
|
request: ProviderRequest,
|
|
providerId: ProviderId | string
|
|
): Promise<void> {
|
|
if (getProviderFileStrategy(providerId) !== 'files-api') return
|
|
|
|
const groups = groupUploadableFiles(request.messages)
|
|
if (groups.length === 0) return
|
|
|
|
const maxBytes = getProviderAttachmentMaxBytes(providerId)
|
|
const ai = providerId === 'google' ? new GoogleGenAI({ apiKey: request.apiKey }) : null
|
|
|
|
for (const group of groups) {
|
|
const [representative] = group
|
|
await assertFileAccessForUpload(representative, request.userId)
|
|
if (providerId === 'openai') {
|
|
await uploadOpenAIFile(representative, request.apiKey, maxBytes, request.abortSignal)
|
|
} else if (ai) {
|
|
await uploadGeminiFile(representative, ai, maxBytes, request.abortSignal)
|
|
}
|
|
for (const file of group) {
|
|
file.providerFileId = representative.providerFileId
|
|
file.providerFileUri = representative.providerFileUri
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifies the caller may read this file before its bytes are uploaded to a provider. Enforced
|
|
* for every caller of {@link uploadLargeFilesToProvider} (not just the agent path), so a forged
|
|
* storage key in a passthrough request cannot exfiltrate another user's file.
|
|
*/
|
|
async function assertFileAccessForUpload(
|
|
file: UserFile,
|
|
userId: string | undefined
|
|
): Promise<void> {
|
|
if (!file.key) {
|
|
throw new Error(`File "${file.name}" has no storage key`)
|
|
}
|
|
if (!userId) {
|
|
throw new Error(`File "${file.name}" requires an authenticated user to upload`)
|
|
}
|
|
const context = resolveTrustedFileContext(file.key, file.context)
|
|
const hasAccess = await verifyFileAccess(file.key, userId, undefined, context, false)
|
|
if (!hasAccess) {
|
|
throw new Error(`File "${file.name}" is not accessible`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Groups large files needing a Files API upload by storage key so a file referenced across
|
|
* multiple messages uploads once; the resulting handle is then applied to every occurrence.
|
|
*/
|
|
function groupUploadableFiles(messages: Message[] | undefined): UserFile[][] {
|
|
const groups = new Map<string, UserFile[]>()
|
|
for (const message of messages ?? []) {
|
|
for (const file of message.files ?? []) {
|
|
if (!file.remoteUrl || file.providerFileId || file.providerFileUri) continue
|
|
const dedupeKey = file.key || file.remoteUrl
|
|
const group = groups.get(dedupeKey)
|
|
if (group) group.push(file)
|
|
else groups.set(dedupeKey, [file])
|
|
}
|
|
}
|
|
return [...groups.values()]
|
|
}
|
|
|
|
/**
|
|
* Reads the file bytes straight from storage via the storage SDK (not by HTTP-fetching the
|
|
* signed URL), so there is no server-side URL fetch to be an SSRF vector and internal
|
|
* object storage works. Bounded by the provider's attachment ceiling.
|
|
*/
|
|
async function downloadFileForUpload(file: UserFile, maxBytes: number): Promise<Blob> {
|
|
const { buffer, contentType } = await downloadServableFileFromStorage(
|
|
file,
|
|
'provider-file-upload',
|
|
logger,
|
|
{ maxBytes }
|
|
)
|
|
return new Blob([new Uint8Array(buffer)], {
|
|
type: contentType || file.type || inferAttachmentMimeType(file),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Uploads to `POST /v1/files` via multipart directly (not the SDK), because the installed
|
|
* `openai` SDK does not type `expires_after`; the bracketed form fields are the documented
|
|
* multipart encoding for the nested object and give the file an auto-expiry.
|
|
*/
|
|
async function uploadOpenAIFile(
|
|
file: UserFile,
|
|
apiKey: string | undefined,
|
|
maxBytes: number,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
const mimeType = inferAttachmentMimeType(file)
|
|
const blob = await downloadFileForUpload(file, maxBytes)
|
|
|
|
const form = new FormData()
|
|
form.append('purpose', mimeType.startsWith('image/') ? 'vision' : 'user_data')
|
|
form.append('expires_after[anchor]', 'created_at')
|
|
form.append('expires_after[seconds]', String(OPENAI_FILE_EXPIRY_SECONDS))
|
|
form.append('file', blob, file.name)
|
|
|
|
const response = await fetch(OPENAI_FILES_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
body: form,
|
|
signal,
|
|
})
|
|
if (!response.ok) {
|
|
const detail = await response.text().catch(() => '')
|
|
throw new Error(`OpenAI file upload failed for "${file.name}" (${response.status}): ${detail}`)
|
|
}
|
|
|
|
const uploaded = (await response.json()) as { id?: string }
|
|
if (!uploaded.id) {
|
|
throw new Error(`OpenAI file upload for "${file.name}" returned no id`)
|
|
}
|
|
file.providerFileId = uploaded.id
|
|
logger.info(`Uploaded "${file.name}" to OpenAI Files API`, { fileId: uploaded.id })
|
|
}
|
|
|
|
async function uploadGeminiFile(
|
|
file: UserFile,
|
|
ai: GoogleGenAI,
|
|
maxBytes: number,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
const mimeType = inferAttachmentMimeType(file)
|
|
const blob = await downloadFileForUpload(file, maxBytes)
|
|
|
|
let uploaded = await ai.files.upload({ file: blob, config: { mimeType, abortSignal: signal } })
|
|
if (!uploaded.name) {
|
|
throw new Error(`Gemini upload for "${file.name}" returned no file name`)
|
|
}
|
|
const uploadedName = uploaded.name
|
|
|
|
const deadline = Date.now() + GEMINI_PROCESSING_TIMEOUT_MS
|
|
while (uploaded.state === FileState.PROCESSING) {
|
|
if (Date.now() > deadline) {
|
|
throw new Error(`Gemini file processing timed out for "${file.name}"`)
|
|
}
|
|
await sleep(GEMINI_POLL_INTERVAL_MS)
|
|
uploaded = await ai.files.get({ name: uploadedName })
|
|
}
|
|
|
|
if (uploaded.state === FileState.FAILED || !uploaded.uri) {
|
|
throw new Error(
|
|
`Gemini file processing failed for "${file.name}": ${getErrorMessage(uploaded.error, 'unknown error')}`
|
|
)
|
|
}
|
|
file.providerFileUri = uploaded.uri
|
|
logger.info(`Uploaded "${file.name}" to Gemini File API`, { fileUri: uploaded.uri })
|
|
}
|