Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

286 lines
11 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 {
formatAttachmentSizes,
getProviderAttachmentMaxBytes,
getProviderFileStrategy,
INLINE_ATTACHMENT_THRESHOLD_BYTES,
inferAttachmentMimeType,
LARGE_FILE_PATH_THRESHOLD_BYTES,
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
}
}
}
/**
* The size past which base64 hydration should stop, because an upload will take over.
*
* This must track {@link shouldUseLargeFilePath}'s crossover exactly. Stopping earlier than the
* strategy actually switches leaves a band with neither base64 nor a handle, which fails the
* request outright — and `remote-url` deliberately switches later than `files-api`, so only
* `files-api` may use the lower threshold. A deployment without cloud storage cannot reach any
* upload path at all, so there base64 has to run all the way to the inline ceiling.
*/
export function getInlineHydrationMaxBytes(providerId: ProviderId | string): number {
const usesUpload =
getProviderFileStrategy(providerId) === 'files-api' && StorageService.hasCloudStorage()
return usesUpload ? LARGE_FILE_PATH_THRESHOLD_BYTES : INLINE_ATTACHMENT_THRESHOLD_BYTES
}
/**
* True when this deployment can actually deliver an oversized attachment through the provider's
* large-file path. A provider strategy alone is not enough — every large-file path reads the
* bytes back out of cloud object storage, so a deployment without it has to keep inlining.
*/
export function canUseProviderLargeFilePath(providerId: ProviderId | string): boolean {
return getProviderFileStrategy(providerId) !== 'inline' && StorageService.hasCloudStorage()
}
/**
* 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). Every large-file path needs cloud storage to read the
* bytes back, so without it the file is left for the inline base64 path instead.
*
* 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 { 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}"`
)
}
if (!StorageService.hasCloudStorage()) {
logger.warn(
`[${requestId}] Sending "${file.name}" inline for "${providerId}": the large-file path needs cloud storage, which is not configured`
)
continue
}
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 })
}