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
849 lines
28 KiB
TypeScript
849 lines
28 KiB
TypeScript
import type Anthropic from '@anthropic-ai/sdk'
|
|
import type { ContentBlock } from '@aws-sdk/client-bedrock-runtime'
|
|
import type { Part } from '@google/genai'
|
|
import type OpenAI from 'openai'
|
|
import {
|
|
getContentType,
|
|
getExtensionFromMimeType,
|
|
getFileExtension,
|
|
isGeneratedDocumentSourceType,
|
|
MIME_TYPE_MAPPING,
|
|
MODEL_SUPPORTED_IMAGE_MIME_TYPES,
|
|
resolveFileType,
|
|
} from '@/lib/uploads/utils/file-utils'
|
|
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'
|
|
|
|
export type AttachmentProvider =
|
|
| 'openai'
|
|
| 'anthropic'
|
|
| 'google'
|
|
| 'bedrock'
|
|
| 'openrouter'
|
|
| 'mistral'
|
|
| 'groq'
|
|
| 'fireworks'
|
|
| 'together'
|
|
| 'baseten'
|
|
| 'ollama'
|
|
| 'vllm'
|
|
| 'litellm'
|
|
| 'xai'
|
|
| 'deepseek'
|
|
| 'cerebras'
|
|
| 'sakana'
|
|
| 'nvidia'
|
|
| 'meta'
|
|
| 'zai'
|
|
| 'kimi'
|
|
|
|
export interface PreparedProviderAttachment {
|
|
file: UserFile
|
|
filename: string
|
|
mimeType: string
|
|
providerMimeType: string
|
|
/** Base64 payload — present only for inlined files (≤ inline threshold). Absent for large uploaded files. */
|
|
base64?: string
|
|
/** `data:` URL — present only for inlined files. Absent for large uploaded files. */
|
|
dataUrl?: string
|
|
text?: string
|
|
extension: string
|
|
contentType: 'image' | 'document' | 'audio' | 'video'
|
|
/** Provider Files API id (OpenAI/Anthropic) when the file was uploaded instead of inlined. */
|
|
providerFileId?: string
|
|
/** Provider File API uri (Gemini) when the file was uploaded instead of inlined. */
|
|
providerFileUri?: string
|
|
/** Short-lived signed HTTPS URL for providers that fetch attachments by remote URL. */
|
|
remoteUrl?: string
|
|
}
|
|
|
|
export type ProviderAttachmentFilenameProjector = (filename: string, extension: string) => string
|
|
|
|
type ProviderMessageInput = {
|
|
role: string
|
|
content?: string | null
|
|
files?: UserFile[]
|
|
}
|
|
|
|
type ProviderFormattedMessage = {
|
|
role: string
|
|
content?: string | null | Array<Record<string, unknown>>
|
|
files?: UserFile[]
|
|
[key: string]: unknown
|
|
}
|
|
|
|
/** 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. */
|
|
export function getProviderFileStrategy(providerId: ProviderId | string): ProviderFileStrategy {
|
|
return getProviderFileAttachment(providerId).strategy
|
|
}
|
|
|
|
/**
|
|
* 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<UserFile, 'size' | 'type'>,
|
|
providerId: ProviderId | string
|
|
): boolean {
|
|
const strategy = getProviderFileAttachment(providerId).strategy
|
|
if (strategy === 'inline') return false
|
|
if (strategy === 'remote-url' && isGeneratedDocumentSourceType(file.type)) return false
|
|
const threshold =
|
|
strategy === 'files-api' ? LARGE_FILE_PATH_THRESHOLD_BYTES : INLINE_ATTACHMENT_THRESHOLD_BYTES
|
|
/**
|
|
* 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'
|
|
|
|
const DOCUMENT_MIME_TYPES = new Set(
|
|
Object.entries(MIME_TYPE_MAPPING)
|
|
.filter(([, contentType]) => contentType === 'document')
|
|
.map(([mimeType]) => mimeType)
|
|
)
|
|
|
|
const OPENAI_DOCUMENT_MIME_TYPES = new Set([...DOCUMENT_MIME_TYPES, 'application/x-yaml'])
|
|
|
|
const GEMINI_INLINE_MIME_TYPES = new Set([...Object.keys(MIME_TYPE_MAPPING), 'application/x-yaml'])
|
|
|
|
const BEDROCK_DOCUMENT_FORMATS = new Set([
|
|
'pdf',
|
|
'csv',
|
|
'doc',
|
|
'docx',
|
|
'xls',
|
|
'xlsx',
|
|
'html',
|
|
'txt',
|
|
'md',
|
|
])
|
|
const BEDROCK_IMAGE_FORMATS = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp'])
|
|
const BEDROCK_VIDEO_FORMATS = new Set(['mp4', 'mov', 'mkv', 'webm'])
|
|
|
|
const UNSUPPORTED_FILE_PROVIDERS = new Set<AttachmentProvider>([
|
|
'deepseek',
|
|
'cerebras',
|
|
'sakana',
|
|
'nvidia',
|
|
'meta',
|
|
'zai',
|
|
])
|
|
|
|
const PROVIDER_SUPPORTED_LABELS: Record<AttachmentProvider, string> = {
|
|
openai: 'images and documents through the Responses API input_image/input_file parts',
|
|
anthropic: 'images, PDFs, and text documents through Claude content blocks',
|
|
google: 'images, audio, video, PDFs, and text documents through Gemini inlineData',
|
|
bedrock: 'Bedrock Converse image, document, and video content blocks',
|
|
openrouter: 'images and PDFs through OpenRouter multimodal message parts',
|
|
mistral: 'images through image_url message parts',
|
|
groq: 'images through image_url message parts on multimodal models',
|
|
fireworks: 'images through image_url message parts on vision models',
|
|
together: 'images through image_url message parts on vision models',
|
|
baseten: 'images through image_url message parts on vision models',
|
|
ollama: 'images through image_url message parts on vision models',
|
|
vllm: 'images through image_url message parts on multimodal models',
|
|
litellm: 'images through image_url message parts on multimodal models',
|
|
xai: 'images through image_url message parts on Grok vision models',
|
|
deepseek: 'no file attachments in the current API adapter',
|
|
cerebras: 'no file attachments in the current API adapter',
|
|
sakana: 'no file attachments in the current API adapter',
|
|
nvidia: 'no file attachments in the current API adapter',
|
|
meta: 'no file attachments in the current API adapter',
|
|
zai: 'no file attachments in the current API adapter',
|
|
kimi: 'images through image_url message parts on multimodal models',
|
|
}
|
|
|
|
export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null {
|
|
if (providerId === 'openai' || providerId === 'azure-openai') return 'openai'
|
|
if (providerId === 'anthropic' || providerId === 'azure-anthropic') return 'anthropic'
|
|
if (providerId === 'google' || providerId === 'vertex') return 'google'
|
|
if (providerId === 'bedrock') return 'bedrock'
|
|
if (providerId === 'openrouter') return 'openrouter'
|
|
if (providerId === 'mistral') return 'mistral'
|
|
if (providerId === 'groq') return 'groq'
|
|
if (providerId === 'fireworks') return 'fireworks'
|
|
if (providerId === 'together') return 'together'
|
|
if (providerId === 'baseten') return 'baseten'
|
|
if (providerId === 'ollama' || providerId === 'ollama-cloud') return 'ollama'
|
|
if (providerId === 'vllm') return 'vllm'
|
|
if (providerId === 'litellm') return 'litellm'
|
|
if (providerId === 'xai') return 'xai'
|
|
if (providerId === 'deepseek') return 'deepseek'
|
|
if (providerId === 'cerebras') return 'cerebras'
|
|
if (providerId === 'sakana') return 'sakana'
|
|
if (providerId === 'nvidia') return 'nvidia'
|
|
if (providerId === 'meta') return 'meta'
|
|
if (providerId === 'zai') return 'zai'
|
|
if (providerId === 'kimi') return 'kimi'
|
|
return null
|
|
}
|
|
|
|
export function supportsFileAttachments(providerId: ProviderId | string): boolean {
|
|
const provider = getAttachmentProvider(providerId)
|
|
return Boolean(provider && !UNSUPPORTED_FILE_PROVIDERS.has(provider))
|
|
}
|
|
|
|
/**
|
|
* 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. 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
|
|
}
|
|
|
|
const MEBIBYTE = 1024 * 1024
|
|
|
|
/**
|
|
* Renders a size and the ceiling it violated, both in one unit derived from the ceiling.
|
|
*
|
|
* 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 formatAttachmentSizes(
|
|
bytes: number,
|
|
limitBytes: number
|
|
): { size: string; limit: string } {
|
|
const divisor = limitBytes % MEBIBYTE === 0 ? MEBIBYTE : 1_000_000
|
|
/**
|
|
* 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, Math.ceil), limit: render(limitBytes, Math.floor) }
|
|
}
|
|
|
|
export function inferAttachmentMimeType(file: UserFile): string {
|
|
const explicitType = file.type?.trim().toLowerCase()
|
|
return resolveFileType({
|
|
name: file.name,
|
|
type: isGeneratedDocumentSourceType(explicitType) ? '' : (explicitType ?? ''),
|
|
}).toLowerCase()
|
|
}
|
|
|
|
function isTextDocumentMimeType(mimeType: string): boolean {
|
|
return (
|
|
mimeType.startsWith('text/') ||
|
|
mimeType === 'application/json' ||
|
|
mimeType === 'application/xml' ||
|
|
mimeType === 'application/x-yaml'
|
|
)
|
|
}
|
|
|
|
function isImageMimeType(mimeType: string): boolean {
|
|
return MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)
|
|
}
|
|
|
|
function isOpenAIDocumentMimeType(mimeType: string): boolean {
|
|
return OPENAI_DOCUMENT_MIME_TYPES.has(mimeType) || isTextDocumentMimeType(mimeType)
|
|
}
|
|
|
|
function getAttachmentContentType(
|
|
mimeType: string
|
|
): PreparedProviderAttachment['contentType'] | null {
|
|
return getContentType(mimeType) || (isTextDocumentMimeType(mimeType) ? 'document' : null)
|
|
}
|
|
|
|
/** True only when this request path transmits the original filename to a model provider. */
|
|
export function isProviderAttachmentFilenameModelBound(
|
|
file: UserFile,
|
|
providerId: ProviderId | string,
|
|
options: { largeFilePathAvailable?: boolean } = {}
|
|
): boolean {
|
|
if (
|
|
providerId === 'openai' &&
|
|
options.largeFilePathAvailable &&
|
|
shouldUseLargeFilePath(file, providerId)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
const provider = getAttachmentProvider(providerId)
|
|
if (!provider) return false
|
|
const contentType = getAttachmentContentType(inferAttachmentMimeType(file))
|
|
if (contentType !== 'document') return false
|
|
|
|
return (
|
|
providerId === 'openai' ||
|
|
provider === 'anthropic' ||
|
|
provider === 'bedrock' ||
|
|
provider === 'openrouter'
|
|
)
|
|
}
|
|
|
|
function getProviderAttachmentFilename(
|
|
attachment: PreparedProviderAttachment,
|
|
projectFilename?: ProviderAttachmentFilenameProjector
|
|
): string {
|
|
return projectFilename?.(attachment.filename, attachment.extension) ?? attachment.filename
|
|
}
|
|
|
|
function sniffImageMimeType(base64: string): string {
|
|
let bytes: Buffer
|
|
try {
|
|
bytes = Buffer.from(base64, 'base64')
|
|
} catch {
|
|
return ''
|
|
}
|
|
|
|
if (
|
|
bytes.length >= 8 &&
|
|
bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
) {
|
|
return 'image/png'
|
|
}
|
|
|
|
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
return 'image/jpeg'
|
|
}
|
|
|
|
if (
|
|
bytes.length >= 6 &&
|
|
(bytes.subarray(0, 6).equals(Buffer.from('GIF87a')) ||
|
|
bytes.subarray(0, 6).equals(Buffer.from('GIF89a')))
|
|
) {
|
|
return 'image/gif'
|
|
}
|
|
|
|
if (
|
|
bytes.length >= 12 &&
|
|
bytes.subarray(0, 4).equals(Buffer.from('RIFF')) &&
|
|
bytes.subarray(8, 12).equals(Buffer.from('WEBP'))
|
|
) {
|
|
return 'image/webp'
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function getAttachmentExtension(file: UserFile, mimeType: string): string {
|
|
if (mimeType === 'text/markdown') return 'md'
|
|
return getExtensionFromMimeType(mimeType) || getFileExtension(file.name)
|
|
}
|
|
|
|
function normalizeProviderMimeType(mimeType: string, provider: AttachmentProvider): string {
|
|
if ((provider === 'anthropic' || provider === 'google') && isTextDocumentMimeType(mimeType)) {
|
|
return 'text/plain'
|
|
}
|
|
return mimeType
|
|
}
|
|
|
|
function decodeBase64Text(base64: string, filename: string): string {
|
|
try {
|
|
return Buffer.from(base64, 'base64').toString('utf8')
|
|
} catch {
|
|
throw new Error(`File "${filename}" could not be decoded as UTF-8 text`)
|
|
}
|
|
}
|
|
|
|
function toDataUrl(mimeType: string, base64: string): string {
|
|
return `data:${mimeType};base64,${base64}`
|
|
}
|
|
|
|
function isMimeTypeSupportedByProvider(
|
|
provider: AttachmentProvider,
|
|
mimeType: string,
|
|
contentType: PreparedProviderAttachment['contentType'],
|
|
extension: string
|
|
): boolean {
|
|
switch (provider) {
|
|
case 'openai':
|
|
return isImageMimeType(mimeType) || isOpenAIDocumentMimeType(mimeType)
|
|
case 'anthropic':
|
|
return (
|
|
isImageMimeType(mimeType) || mimeType === PDF_MIME_TYPE || isTextDocumentMimeType(mimeType)
|
|
)
|
|
case 'google':
|
|
return GEMINI_INLINE_MIME_TYPES.has(mimeType) || isTextDocumentMimeType(mimeType)
|
|
case 'bedrock':
|
|
return (
|
|
(contentType === 'image' && BEDROCK_IMAGE_FORMATS.has(extension)) ||
|
|
(contentType === 'document' && BEDROCK_DOCUMENT_FORMATS.has(extension)) ||
|
|
(contentType === 'video' && BEDROCK_VIDEO_FORMATS.has(extension))
|
|
)
|
|
case 'openrouter':
|
|
return isImageMimeType(mimeType) || mimeType === PDF_MIME_TYPE
|
|
case 'mistral':
|
|
case 'groq':
|
|
case 'fireworks':
|
|
case 'together':
|
|
case 'baseten':
|
|
case 'ollama':
|
|
case 'vllm':
|
|
case 'litellm':
|
|
case 'xai':
|
|
case 'kimi':
|
|
return isImageMimeType(mimeType)
|
|
case 'deepseek':
|
|
case 'cerebras':
|
|
case 'sakana':
|
|
case 'nvidia':
|
|
case 'meta':
|
|
case 'zai':
|
|
return false
|
|
default: {
|
|
const _exhaustive: never = provider
|
|
return _exhaustive
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateProviderSupport(
|
|
attachment: Omit<PreparedProviderAttachment, 'providerMimeType' | 'dataUrl' | 'text'>,
|
|
provider: AttachmentProvider,
|
|
providerId: ProviderId | string
|
|
) {
|
|
const { filename, mimeType, contentType, extension } = attachment
|
|
const supportedLabel = PROVIDER_SUPPORTED_LABELS[provider]
|
|
|
|
const supported = isMimeTypeSupportedByProvider(provider, mimeType, contentType, extension)
|
|
|
|
if (!supported) {
|
|
throw new Error(
|
|
`File "${filename}" has MIME type "${mimeType}", which is not supported by provider "${providerId}". Supported attachments: ${supportedLabel}.`
|
|
)
|
|
}
|
|
}
|
|
|
|
export function prepareProviderAttachments(
|
|
files: UserFile[] | undefined,
|
|
providerId: ProviderId | string
|
|
): PreparedProviderAttachment[] {
|
|
if (!files || files.length === 0) return []
|
|
|
|
const provider = getAttachmentProvider(providerId)
|
|
if (!provider) {
|
|
throw new Error(`File attachments are not supported for provider "${providerId}"`)
|
|
}
|
|
|
|
if (UNSUPPORTED_FILE_PROVIDERS.has(provider)) {
|
|
throw new Error(
|
|
`File attachments are not supported for provider "${providerId}" in the current adapter. Supported attachments: ${PROVIDER_SUPPORTED_LABELS[provider]}.`
|
|
)
|
|
}
|
|
|
|
return files.map((file) => {
|
|
const declaredMimeType = inferAttachmentMimeType(file)
|
|
const contentType = getAttachmentContentType(declaredMimeType)
|
|
|
|
if (!contentType) {
|
|
throw new Error(
|
|
`File "${file.name}" has MIME type "${declaredMimeType}", which is not supported by provider "${providerId}". Supported attachments: ${PROVIDER_SUPPORTED_LABELS[provider]}.`
|
|
)
|
|
}
|
|
|
|
const maxBytes = getProviderAttachmentMaxBytes(providerId)
|
|
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}"`
|
|
)
|
|
}
|
|
|
|
const providerFileId = file.providerFileId
|
|
const providerFileUri = file.providerFileUri
|
|
const remoteUrl = file.remoteUrl
|
|
const hasHandle = Boolean(providerFileId || providerFileUri || remoteUrl)
|
|
|
|
if (!file.base64 && !hasHandle) {
|
|
throw new Error(`File "${file.name}" could not be read for provider "${providerId}"`)
|
|
}
|
|
|
|
const sniffedImageMimeType =
|
|
contentType === 'image' && file.base64 ? sniffImageMimeType(file.base64) : ''
|
|
if (contentType === 'image' && file.base64 && !sniffedImageMimeType) {
|
|
throw new Error(
|
|
`Image bytes in "${file.name}" are not a supported model image format (declared MIME type "${declaredMimeType}"). Supported image formats: image/jpeg, image/png, image/gif, image/webp.`
|
|
)
|
|
}
|
|
|
|
const mimeType = sniffedImageMimeType || declaredMimeType
|
|
const extension = getAttachmentExtension(file, mimeType)
|
|
const attachment = {
|
|
file,
|
|
filename: file.name,
|
|
mimeType,
|
|
base64: file.base64,
|
|
extension,
|
|
contentType,
|
|
}
|
|
|
|
validateProviderSupport(attachment, provider, providerId)
|
|
|
|
const providerMimeType = normalizeProviderMimeType(mimeType, provider)
|
|
return {
|
|
...attachment,
|
|
providerMimeType,
|
|
providerFileId,
|
|
providerFileUri,
|
|
remoteUrl,
|
|
...(file.base64 && { dataUrl: toDataUrl(providerMimeType, file.base64) }),
|
|
...(file.base64 &&
|
|
isTextDocumentMimeType(mimeType) && {
|
|
text: decodeBase64Text(file.base64, file.name),
|
|
}),
|
|
}
|
|
})
|
|
}
|
|
|
|
type OpenAIResponsesInputContent = OpenAI.Responses.ResponseInputContent
|
|
type OpenAIChatContentPart = OpenAI.Chat.Completions.ChatCompletionContentPart
|
|
type AnthropicImageMediaType = Anthropic.Messages.Base64ImageSource['media_type']
|
|
|
|
export function buildOpenAIMessageContent(
|
|
content: string | null | undefined,
|
|
files: UserFile[] | undefined,
|
|
providerId: ProviderId | string,
|
|
projectFilename?: ProviderAttachmentFilenameProjector
|
|
): string | OpenAIResponsesInputContent[] {
|
|
const attachments = prepareProviderAttachments(files, providerId)
|
|
if (attachments.length === 0) return content ?? ''
|
|
|
|
const parts: OpenAIResponsesInputContent[] = []
|
|
if (content) {
|
|
parts.push({ type: 'input_text', text: content } satisfies OpenAI.Responses.ResponseInputText)
|
|
}
|
|
|
|
for (const attachment of attachments) {
|
|
if (attachment.contentType === 'image') {
|
|
parts.push(
|
|
attachment.providerFileId
|
|
? ({
|
|
type: 'input_image',
|
|
file_id: attachment.providerFileId,
|
|
detail: 'auto',
|
|
} satisfies OpenAI.Responses.ResponseInputImage)
|
|
: ({
|
|
type: 'input_image',
|
|
image_url: attachment.dataUrl,
|
|
detail: 'auto',
|
|
} satisfies OpenAI.Responses.ResponseInputImage)
|
|
)
|
|
} else {
|
|
parts.push(
|
|
attachment.providerFileId
|
|
? ({
|
|
type: 'input_file',
|
|
file_id: attachment.providerFileId,
|
|
} satisfies OpenAI.Responses.ResponseInputFile)
|
|
: ({
|
|
type: 'input_file',
|
|
filename: getProviderAttachmentFilename(attachment, projectFilename),
|
|
file_data: attachment.dataUrl,
|
|
} satisfies OpenAI.Responses.ResponseInputFile)
|
|
)
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
export function buildAnthropicMessageContent(
|
|
content: string | null | undefined,
|
|
files: UserFile[] | undefined,
|
|
providerId: ProviderId | string,
|
|
projectFilename?: ProviderAttachmentFilenameProjector
|
|
): Anthropic.Messages.ContentBlockParam[] {
|
|
const parts: Anthropic.Messages.ContentBlockParam[] = []
|
|
if (content) {
|
|
parts.push({ type: 'text', text: content } satisfies Anthropic.Messages.TextBlockParam)
|
|
}
|
|
|
|
for (const attachment of prepareProviderAttachments(files, providerId)) {
|
|
if (attachment.contentType === 'image') {
|
|
parts.push({
|
|
type: 'image',
|
|
source: attachment.remoteUrl
|
|
? ({ type: 'url', url: attachment.remoteUrl } satisfies Anthropic.Messages.URLImageSource)
|
|
: ({
|
|
type: 'base64',
|
|
media_type: attachment.providerMimeType as AnthropicImageMediaType,
|
|
data: attachment.base64 ?? '',
|
|
} satisfies Anthropic.Messages.Base64ImageSource),
|
|
} satisfies Anthropic.Messages.ImageBlockParam)
|
|
} else if (attachment.remoteUrl) {
|
|
if (attachment.mimeType !== PDF_MIME_TYPE) {
|
|
throw new Error(
|
|
`Document "${attachment.filename}" (${attachment.mimeType}) is too large to send to provider "${providerId}". Only PDFs and images are supported above the inline limit — convert it to PDF or reduce its size.`
|
|
)
|
|
}
|
|
parts.push({
|
|
type: 'document',
|
|
source: { type: 'url', url: attachment.remoteUrl },
|
|
title: getProviderAttachmentFilename(attachment, projectFilename),
|
|
} satisfies Anthropic.Messages.DocumentBlockParam)
|
|
} else if (attachment.text) {
|
|
parts.push({
|
|
type: 'document',
|
|
source: {
|
|
type: 'text',
|
|
media_type: 'text/plain',
|
|
data: attachment.text,
|
|
},
|
|
title: getProviderAttachmentFilename(attachment, projectFilename),
|
|
} satisfies Anthropic.Messages.DocumentBlockParam)
|
|
} else {
|
|
parts.push({
|
|
type: 'document',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'application/pdf',
|
|
data: attachment.base64 ?? '',
|
|
},
|
|
title: getProviderAttachmentFilename(attachment, projectFilename),
|
|
} satisfies Anthropic.Messages.DocumentBlockParam)
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
export function buildGeminiMessageParts(
|
|
content: string | null | undefined,
|
|
files: UserFile[] | undefined,
|
|
providerId: ProviderId | string
|
|
): Part[] {
|
|
const parts: Part[] = []
|
|
if (content) {
|
|
parts.push({ text: content } satisfies Part)
|
|
}
|
|
|
|
for (const attachment of prepareProviderAttachments(files, providerId)) {
|
|
parts.push(
|
|
attachment.providerFileUri
|
|
? ({
|
|
fileData: {
|
|
fileUri: attachment.providerFileUri,
|
|
mimeType: attachment.providerMimeType,
|
|
},
|
|
} satisfies Part)
|
|
: ({
|
|
inlineData: {
|
|
mimeType: attachment.providerMimeType,
|
|
data: attachment.base64 ?? '',
|
|
},
|
|
} satisfies Part)
|
|
)
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
export function buildOpenAICompatibleChatContent(
|
|
content: string | null | undefined,
|
|
files: UserFile[] | undefined,
|
|
providerId: ProviderId | string
|
|
): string | OpenAIChatContentPart[] {
|
|
const attachments = prepareProviderAttachments(files, providerId)
|
|
if (attachments.length === 0) return content ?? ''
|
|
|
|
const parts: OpenAIChatContentPart[] = []
|
|
if (content) {
|
|
parts.push({
|
|
type: 'text',
|
|
text: content,
|
|
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPartText)
|
|
}
|
|
|
|
for (const attachment of attachments) {
|
|
parts.push({
|
|
type: 'image_url',
|
|
image_url: {
|
|
url: attachment.remoteUrl ?? attachment.dataUrl ?? '',
|
|
},
|
|
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPartImage)
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
export function buildOpenRouterMessageContent(
|
|
content: string | null | undefined,
|
|
files: UserFile[] | undefined,
|
|
providerId: ProviderId | string,
|
|
projectFilename?: ProviderAttachmentFilenameProjector
|
|
): string | OpenAIChatContentPart[] {
|
|
const attachments = prepareProviderAttachments(files, providerId)
|
|
if (attachments.length === 0) return content ?? ''
|
|
|
|
const parts: OpenAIChatContentPart[] = []
|
|
if (content) {
|
|
parts.push({
|
|
type: 'text',
|
|
text: content,
|
|
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPartText)
|
|
}
|
|
|
|
for (const attachment of attachments) {
|
|
if (attachment.contentType === 'image') {
|
|
parts.push({
|
|
type: 'image_url',
|
|
image_url: { url: attachment.remoteUrl ?? attachment.dataUrl ?? '' },
|
|
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPartImage)
|
|
} else {
|
|
parts.push({
|
|
type: 'file',
|
|
file: {
|
|
filename: getProviderAttachmentFilename(attachment, projectFilename),
|
|
file_data: attachment.remoteUrl ?? attachment.dataUrl ?? '',
|
|
},
|
|
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPart.File)
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
function sanitizeBedrockName(filename: string): string {
|
|
const baseName = filename.replace(/\.[^/.]+$/, '').replace(/[^a-zA-Z0-9\s()[\]-]/g, ' ')
|
|
const compacted = baseName.replace(/\s+/g, ' ').trim()
|
|
return compacted || 'Document'
|
|
}
|
|
|
|
function getBedrockDocumentName(
|
|
attachment: PreparedProviderAttachment,
|
|
projectFilename?: ProviderAttachmentFilenameProjector
|
|
): string {
|
|
const projectedFilename = getProviderAttachmentFilename(attachment, projectFilename)
|
|
return projectedFilename === attachment.filename
|
|
? sanitizeBedrockName(attachment.filename)
|
|
: 'Document'
|
|
}
|
|
|
|
function getBedrockDocumentFormat(attachment: PreparedProviderAttachment): string {
|
|
if (attachment.extension === 'md' || attachment.mimeType === 'text/markdown') return 'md'
|
|
if (attachment.extension === 'txt' || attachment.mimeType === 'text/plain') return 'txt'
|
|
return attachment.extension || 'txt'
|
|
}
|
|
|
|
function getBedrockImageFormat(attachment: PreparedProviderAttachment): string {
|
|
return attachment.extension === 'jpg' ? 'jpeg' : attachment.extension
|
|
}
|
|
|
|
export function buildBedrockMessageContent(
|
|
content: string | null | undefined,
|
|
files: UserFile[] | undefined,
|
|
providerId: ProviderId | string,
|
|
projectFilename?: ProviderAttachmentFilenameProjector
|
|
): ContentBlock[] {
|
|
const parts: ContentBlock[] = []
|
|
if (content) {
|
|
parts.push({ text: content } as ContentBlock.TextMember)
|
|
}
|
|
|
|
for (const attachment of prepareProviderAttachments(files, providerId)) {
|
|
const bytes = Buffer.from(attachment.base64 ?? '', 'base64')
|
|
if (attachment.contentType === 'image') {
|
|
parts.push({
|
|
image: {
|
|
format: getBedrockImageFormat(attachment) as ContentBlock.ImageMember['image']['format'],
|
|
source: { bytes },
|
|
},
|
|
} as ContentBlock.ImageMember)
|
|
} else if (attachment.contentType === 'video') {
|
|
parts.push({
|
|
video: {
|
|
format: attachment.extension as ContentBlock.VideoMember['video']['format'],
|
|
source: { bytes },
|
|
},
|
|
} as ContentBlock.VideoMember)
|
|
} else {
|
|
parts.push({
|
|
document: {
|
|
format: getBedrockDocumentFormat(
|
|
attachment
|
|
) as ContentBlock.DocumentMember['document']['format'],
|
|
name: getBedrockDocumentName(attachment, projectFilename),
|
|
source: { bytes },
|
|
},
|
|
} as ContentBlock.DocumentMember)
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
const SDK_NATIVE_ATTACHMENT_PROVIDERS = new Set<AttachmentProvider>([
|
|
'openai',
|
|
'anthropic',
|
|
'google',
|
|
'bedrock',
|
|
])
|
|
|
|
export function formatMessagesForProvider(
|
|
messages: ProviderMessageInput[],
|
|
providerId: ProviderId | string,
|
|
projectFilename?: ProviderAttachmentFilenameProjector
|
|
): ProviderFormattedMessage[] {
|
|
const provider = getAttachmentProvider(providerId)
|
|
if (provider && SDK_NATIVE_ATTACHMENT_PROVIDERS.has(provider)) {
|
|
return messages as ProviderFormattedMessage[]
|
|
}
|
|
|
|
return messages.map((message) => {
|
|
if (!message.files?.length || (message.role !== 'user' && message.role !== 'assistant')) {
|
|
return message as ProviderFormattedMessage
|
|
}
|
|
|
|
if (provider === 'openrouter') {
|
|
const { files: _omit, ...rest } = message
|
|
return {
|
|
...rest,
|
|
content: buildOpenRouterMessageContent(
|
|
message.content,
|
|
message.files,
|
|
providerId,
|
|
projectFilename
|
|
) as string | Array<Record<string, unknown>>,
|
|
}
|
|
}
|
|
|
|
const { files: _omit, ...rest } = message
|
|
return {
|
|
...rest,
|
|
content: buildOpenAICompatibleChatContent(message.content, message.files, providerId) as
|
|
| string
|
|
| Array<Record<string, unknown>>,
|
|
}
|
|
})
|
|
}
|