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
408 lines
11 KiB
TypeScript
408 lines
11 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { UserFile } from '@/executor/types'
|
|
import {
|
|
buildAnthropicMessageContent,
|
|
buildBedrockMessageContent,
|
|
buildGeminiMessageParts,
|
|
buildOpenAICompatibleChatContent,
|
|
buildOpenAIMessageContent,
|
|
buildOpenRouterMessageContent,
|
|
formatMessagesForProvider,
|
|
getProviderAttachmentMaxBytes,
|
|
getProviderFileStrategy,
|
|
INLINE_ATTACHMENT_THRESHOLD_BYTES,
|
|
inferAttachmentMimeType,
|
|
prepareProviderAttachments,
|
|
shouldUseLargeFilePath,
|
|
} from '@/providers/attachments'
|
|
|
|
const imageFile: UserFile = {
|
|
id: 'file-1',
|
|
name: 'example.png',
|
|
url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace',
|
|
size: 128,
|
|
type: 'image/png',
|
|
key: 'workspace/ws-1/example.png',
|
|
base64: 'iVBORw0KGgo=',
|
|
}
|
|
|
|
const pdfFile: UserFile = {
|
|
id: 'file-2',
|
|
name: 'example.pdf',
|
|
url: '/api/files/serve/workspace%2Fws-1%2Fexample.pdf?context=workspace',
|
|
size: 256,
|
|
type: 'application/pdf',
|
|
key: 'workspace/ws-1/example.pdf',
|
|
base64: 'cGRm',
|
|
}
|
|
|
|
const markdownFile: UserFile = {
|
|
id: 'file-3',
|
|
name: 'notes.md',
|
|
url: '/api/files/serve/workspace%2Fws-1%2Fnotes.md?context=workspace',
|
|
size: 17,
|
|
type: 'text/markdown',
|
|
key: 'workspace/ws-1/notes.md',
|
|
base64: Buffer.from('# Notes\n\nHello').toString('base64'),
|
|
}
|
|
|
|
describe('provider attachments', () => {
|
|
it('infers MIME type from filename when file type is generic', () => {
|
|
expect(
|
|
inferAttachmentMimeType({
|
|
...imageFile,
|
|
type: 'application/octet-stream',
|
|
})
|
|
).toBe('image/png')
|
|
})
|
|
|
|
it('formats OpenAI Responses content with text, image, and file parts', () => {
|
|
const content = buildOpenAIMessageContent(
|
|
'Analyze these files',
|
|
[imageFile, pdfFile, markdownFile],
|
|
'openai'
|
|
)
|
|
|
|
expect(content).toEqual([
|
|
{ type: 'input_text', text: 'Analyze these files' },
|
|
{
|
|
type: 'input_image',
|
|
image_url: 'data:image/png;base64,iVBORw0KGgo=',
|
|
detail: 'auto',
|
|
},
|
|
{
|
|
type: 'input_file',
|
|
filename: 'example.pdf',
|
|
file_data: 'data:application/pdf;base64,cGRm',
|
|
},
|
|
{
|
|
type: 'input_file',
|
|
filename: 'notes.md',
|
|
file_data: `data:text/markdown;base64,${markdownFile.base64}`,
|
|
},
|
|
])
|
|
})
|
|
|
|
it('formats Anthropic content with image, PDF document, and text document blocks', () => {
|
|
const content = buildAnthropicMessageContent(
|
|
'Analyze these files',
|
|
[imageFile, pdfFile, markdownFile],
|
|
'anthropic'
|
|
)
|
|
|
|
expect(content).toEqual([
|
|
{ type: 'text', text: 'Analyze these files' },
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/png',
|
|
data: 'iVBORw0KGgo=',
|
|
},
|
|
},
|
|
{
|
|
type: 'document',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'application/pdf',
|
|
data: 'cGRm',
|
|
},
|
|
title: 'example.pdf',
|
|
},
|
|
{
|
|
type: 'document',
|
|
source: {
|
|
type: 'text',
|
|
media_type: 'text/plain',
|
|
data: '# Notes\n\nHello',
|
|
},
|
|
title: 'notes.md',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('formats Gemini content with text and inline data parts', () => {
|
|
const parts = buildGeminiMessageParts('Analyze this file', [imageFile, markdownFile], 'google')
|
|
|
|
expect(parts).toEqual([
|
|
{ text: 'Analyze this file' },
|
|
{
|
|
inlineData: {
|
|
mimeType: 'image/png',
|
|
data: 'iVBORw0KGgo=',
|
|
},
|
|
},
|
|
{
|
|
inlineData: {
|
|
mimeType: 'text/plain',
|
|
data: markdownFile.base64,
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
it('formats Bedrock content with native document blocks', () => {
|
|
const parts = buildBedrockMessageContent('Analyze this file', [markdownFile], 'bedrock')
|
|
|
|
expect(parts).toEqual([
|
|
{ text: 'Analyze this file' },
|
|
{
|
|
document: {
|
|
format: 'md',
|
|
name: 'notes',
|
|
source: {
|
|
bytes: Buffer.from(markdownFile.base64, 'base64'),
|
|
},
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
it('formats OpenRouter images and PDFs with native multimodal message parts', () => {
|
|
const content = buildOpenRouterMessageContent(
|
|
'Analyze these files',
|
|
[imageFile, pdfFile],
|
|
'openrouter'
|
|
)
|
|
|
|
expect(content).toEqual([
|
|
{ type: 'text', text: 'Analyze these files' },
|
|
{
|
|
type: 'image_url',
|
|
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
|
},
|
|
{
|
|
type: 'file',
|
|
file: {
|
|
filename: 'example.pdf',
|
|
file_data: 'data:application/pdf;base64,cGRm',
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
it('formats image-only provider messages and strips file fields', () => {
|
|
const messages = formatMessagesForProvider(
|
|
[{ role: 'user', content: 'Analyze this image', files: [imageFile] }],
|
|
'groq'
|
|
)
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'Analyze this image' },
|
|
{
|
|
type: 'image_url',
|
|
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
|
},
|
|
],
|
|
},
|
|
])
|
|
})
|
|
|
|
it('fails fast for unsupported MIME types', () => {
|
|
expect(() =>
|
|
prepareProviderAttachments(
|
|
[
|
|
{
|
|
...imageFile,
|
|
name: 'archive.zip',
|
|
type: 'application/zip',
|
|
},
|
|
],
|
|
'openai'
|
|
)
|
|
).toThrow('application/zip')
|
|
})
|
|
|
|
it('sniffs image bytes and corrects a wrong declared image MIME type', () => {
|
|
const content = buildAnthropicMessageContent(
|
|
'Analyze this image',
|
|
[
|
|
{
|
|
...imageFile,
|
|
name: 'wrong.ico',
|
|
type: 'image/x-icon',
|
|
},
|
|
],
|
|
'anthropic'
|
|
)
|
|
|
|
expect(content[1]).toEqual({
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/png',
|
|
data: 'iVBORw0KGgo=',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('rejects image attachments when the bytes are not a supported image format', () => {
|
|
expect(() =>
|
|
prepareProviderAttachments(
|
|
[
|
|
{
|
|
...imageFile,
|
|
name: 'not-an-image.png',
|
|
base64: Buffer.from('not an image').toString('base64'),
|
|
},
|
|
],
|
|
'anthropic'
|
|
)
|
|
).toThrow('not a supported model image format')
|
|
})
|
|
|
|
it('rejects documents for image-only providers', () => {
|
|
expect(() =>
|
|
formatMessagesForProvider(
|
|
[{ role: 'user', content: 'Analyze this file', files: [pdfFile] }],
|
|
'groq'
|
|
)
|
|
).toThrow('Supported attachments: images')
|
|
})
|
|
|
|
it('rejects providers without file attachment support', () => {
|
|
expect(() =>
|
|
formatMessagesForProvider(
|
|
[{ role: 'user', content: 'Analyze this file', files: [imageFile] }],
|
|
'deepseek'
|
|
)
|
|
).toThrow('not supported')
|
|
})
|
|
})
|
|
|
|
describe('provider large-file capability', () => {
|
|
it('reports per-provider strategy and ceiling, defaulting others to inline', () => {
|
|
expect(getProviderFileStrategy('openai')).toBe('files-api')
|
|
expect(getProviderFileStrategy('google')).toBe('files-api')
|
|
expect(getProviderFileStrategy('anthropic')).toBe('remote-url')
|
|
expect(getProviderFileStrategy('groq')).toBe('remote-url')
|
|
expect(getProviderFileStrategy('bedrock')).toBe('inline')
|
|
expect(getProviderFileStrategy('azure-openai')).toBe('inline')
|
|
expect(getProviderFileStrategy('vertex')).toBe('inline')
|
|
|
|
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)
|
|
})
|
|
|
|
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 }
|
|
expect(shouldUseLargeFilePath(small, 'openai')).toBe(false)
|
|
expect(shouldUseLargeFilePath(large, 'openai')).toBe(true)
|
|
expect(shouldUseLargeFilePath(large, 'bedrock')).toBe(false)
|
|
})
|
|
|
|
it('references uploaded OpenAI files by file_id instead of inlining base64', () => {
|
|
const content = buildOpenAIMessageContent(
|
|
'Analyze',
|
|
[
|
|
{ ...imageFile, base64: undefined, providerFileId: 'file-img' },
|
|
{ ...pdfFile, base64: undefined, providerFileId: 'file-doc' },
|
|
],
|
|
'openai'
|
|
)
|
|
expect(content).toEqual([
|
|
{ type: 'input_text', text: 'Analyze' },
|
|
{ type: 'input_image', file_id: 'file-img', detail: 'auto' },
|
|
{ type: 'input_file', file_id: 'file-doc' },
|
|
])
|
|
})
|
|
|
|
it('references large Anthropic files via url content-block sources', () => {
|
|
const content = buildAnthropicMessageContent(
|
|
'Analyze',
|
|
[
|
|
{ ...imageFile, base64: undefined, remoteUrl: 'https://signed/img.png' },
|
|
{ ...pdfFile, base64: undefined, remoteUrl: 'https://signed/doc.pdf' },
|
|
],
|
|
'anthropic'
|
|
)
|
|
expect(content).toEqual([
|
|
{ type: 'text', text: 'Analyze' },
|
|
{ type: 'image', source: { type: 'url', url: 'https://signed/img.png' } },
|
|
{
|
|
type: 'document',
|
|
source: { type: 'url', url: 'https://signed/doc.pdf' },
|
|
title: 'example.pdf',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('references uploaded Gemini files via fileData uri', () => {
|
|
const parts = buildGeminiMessageParts(
|
|
'Analyze',
|
|
[{ ...imageFile, base64: undefined, providerFileUri: 'https://files/abc' }],
|
|
'google'
|
|
)
|
|
expect(parts).toEqual([
|
|
{ text: 'Analyze' },
|
|
{ fileData: { fileUri: 'https://files/abc', mimeType: 'image/png' } },
|
|
])
|
|
})
|
|
|
|
it('passes a remote url to OpenAI-compatible providers instead of a data url', () => {
|
|
const content = buildOpenAICompatibleChatContent(
|
|
'Analyze',
|
|
[{ ...imageFile, base64: undefined, remoteUrl: 'https://signed/img.png' }],
|
|
'groq'
|
|
)
|
|
expect(content).toEqual([
|
|
{ type: 'text', text: 'Analyze' },
|
|
{ type: 'image_url', image_url: { url: 'https://signed/img.png' } },
|
|
])
|
|
})
|
|
|
|
it('rejects oversized non-PDF text documents on Anthropic (url source supports PDFs/images only)', () => {
|
|
expect(() =>
|
|
buildAnthropicMessageContent(
|
|
'Analyze',
|
|
[
|
|
{
|
|
...markdownFile,
|
|
type: 'text/csv',
|
|
name: 'data.csv',
|
|
base64: undefined,
|
|
remoteUrl: 'https://signed/data.csv',
|
|
},
|
|
],
|
|
'anthropic'
|
|
)
|
|
).toThrow('Only PDFs and images are supported')
|
|
})
|
|
|
|
it('references large Anthropic PDFs via a url document source', () => {
|
|
const content = buildAnthropicMessageContent(
|
|
'Analyze',
|
|
[{ ...pdfFile, base64: undefined, remoteUrl: 'https://signed/doc.pdf' }],
|
|
'anthropic'
|
|
)
|
|
expect(content).toEqual([
|
|
{ type: 'text', text: 'Analyze' },
|
|
{
|
|
type: 'document',
|
|
source: { type: 'url', url: 'https://signed/doc.pdf' },
|
|
title: 'example.pdf',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('rejects files above the provider ceiling', () => {
|
|
const huge = {
|
|
...imageFile,
|
|
size: getProviderAttachmentMaxBytes('openai') + 1,
|
|
base64: undefined,
|
|
providerFileId: 'file-img',
|
|
}
|
|
expect(() => buildOpenAIMessageContent('Analyze', [huge], 'openai')).toThrow('exceeds the')
|
|
})
|
|
})
|