Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/sim/blocks/blocks/image_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,10 @@ export const ImageGeneratorV2Block: BlockConfig = {
},
inputs: {
provider: { type: 'string', description: 'Image generation provider' },
prompt: { type: 'string', description: 'Image description prompt' },
prompt: {
type: 'string',
description: 'Image description, up to 4,000 characters. Some models have a lower limit.',
},
model: { type: 'string', description: 'Image generation model' },
size: { type: 'string', description: 'Image size' },
aspectRatio: { type: 'string', description: 'Image aspect ratio' },
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/blocks/blocks/video_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1834,7 +1834,7 @@ export const VideoGeneratorV2Block: BlockConfig = {
const model = params.provider === 'falai' ? getFalVideoModel(params.model) : undefined
const useFalFields = model && !model.legacyId
const setting = (legacy: string, catalog: string) => {
const value = params[useFalFields ? catalog : legacy]
const value = useFalFields ? (params[catalog] ?? params[legacy]) : params[legacy]
return useFalFields && value === FAL_MODEL_DEFAULT.id ? undefined : value
}
const duration = setting('duration', 'falDuration')
Expand Down Expand Up @@ -1956,7 +1956,7 @@ export const VideoGeneratorV3Block: BlockConfig = {
promptOptimizer: parseOptionalBooleanInput(
setting('promptOptimizer', model?.settings?.promptOptimizer)
),
endpoint: model?.protocol === 'minimax-v1' ? params.endpoint : undefined,
endpoint: params.provider === 'minimax' ? params.endpoint : undefined,
cameraControl:
model?.protocol === 'luma-legacy' && params.cameraControl
? typeof params.cameraControl === 'string'
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/lib/internal/image/request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ImageGenerationInput, ImageProvider } from '@/lib/internal/image/schema'
import { getImageModel, imageModelDefaults, imageOptionFields } from '@/lib/media/image-catalog'

/** Sim limits image prompts independently of larger provider model allowances. */
const MAX_IMAGE_PROMPT_CHARACTERS = 4000

/** Builds only supported settings for the selected image model before a paid request is sent. */
export function buildImageGenerationRequest(input: ImageGenerationInput) {
const model = getImageModel(
Expand All @@ -12,7 +15,10 @@ export function buildImageGenerationRequest(input: ImageGenerationInput) {
throw new Error('Image generation returns one image per tool execution; numImages must be 1')
}
const minLength = Math.max(3, model.minPromptLength ?? 3)
const maxLength = Math.min(4000, model.maxPromptLength ?? 4000)
const maxLength = Math.min(
MAX_IMAGE_PROMPT_CHARACTERS,
model.maxPromptLength ?? MAX_IMAGE_PROMPT_CHARACTERS
)
if (input.prompt.length < minLength || input.prompt.length > maxLength) {
throw new Error(
`Prompt must be between ${minLength} and ${maxLength} characters for ${model.label}`
Expand Down
173 changes: 153 additions & 20 deletions apps/sim/lib/internal/video/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ describe('Video provider client', () => {
isValid: true,
resolvedIP: '203.0.113.1',
})
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
new Response(Buffer.from('video'))
)
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockReset()
.mockResolvedValue(new Response(Buffer.from('video')))
})

afterEach(() => {
Expand Down Expand Up @@ -204,23 +204,6 @@ describe('Video provider client', () => {
},
},
},
{
provider: 'veo' as const,
model: 'gemini-omni-1.1-flash',
submit: '/v1beta/interactions',
poll: '/v1beta/interactions/job',
payload: { model: 'gemini-omni-1.1-flash', input: 'A cinematic sunrise' },
created: { id: 'job', status: 'in_progress' },
completed: {
status: 'completed',
steps: [
{
type: 'model_output',
content: [{ type: 'video', mime_type: 'video/mp4', data: 'dmlkZW8=' }],
},
],
},
},
])('executes $model through its native submit, poll and result protocol', async (testCase) => {
let submissions = 0
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
Expand Down Expand Up @@ -251,6 +234,156 @@ describe('Video provider client', () => {
expect(submissions).toBe(1)
})

describe('Gemini URI delivery', () => {
const input = { provider: 'veo' as const, apiKey: 'key', prompt: 'A cinematic sunrise' }
const videoUri =
'https://generativelanguage.googleapis.com/v1beta/files/video-file:download?alt=media'
const completed = (uri = videoUri) => ({
id: 'interaction-1',
status: 'completed',
steps: [{ type: 'model_output', content: [{ type: 'video', uri }] }],
})

it('downloads a normal-sized video after Files becomes ACTIVE without reading inline polling data', async () => {
const video = Buffer.alloc(2 * 1024 * 1024)
let filePolls = 0
let submissions = 0
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
const path = new URL(url).pathname
if (path === '/v1beta/interactions' && init?.method === 'POST') {
submissions++
const body = JSON.parse(String(init.body))
return jsonResponse(
body.background ? { id: 'interaction-1', status: 'in_progress' } : completed()
)
}
if (path === '/v1beta/interactions/interaction-1')
return jsonResponse({
...completed(),
steps: [
{
type: 'model_output',
content: [{ type: 'video', data: video.toString('base64') }],
},
],
})
if (path === '/v1beta/files/video-file') {
filePolls++
expect(new Headers(init?.headers).get('x-goog-api-key')).toBe('key')
return jsonResponse({ state: filePolls === 1 ? 'PROCESSING' : 'ACTIVE' })
}
return new Response(null, { status: 404 })
})
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockImplementationOnce(
async (_url, _ip, options) => {
expect(new Headers(options.headers).get('x-goog-api-key')).toBe('key')
return filePolls >= 2
? new Response(video)
: new Response('File is not ACTIVE', { status: 409 })
}
)
const result = generateVideo(input, { requestId: 'google-uri' })
const completion = result.then((output) => {
expect(output.buffer.equals(video)).toBe(true)
expect(output.jobId).toBe('interaction-1')
})
await Promise.all([
completion,
vi.advanceTimersByTimeAsync(0).then(() => vi.advanceTimersByTimeAsync(5000)),
])
expect(submissions).toBe(1)
})

it.each([
{ state: 'FAILED', error: /Google video processing failed/ },
{ state: 'PROCESSING', error: /Google video processing timed out/ },
])('does not download a file that remains $state', async ({ state, error }) => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(jsonResponse(completed()))
.mockImplementation(() => Promise.resolve(jsonResponse({ state })))
)
const rejection = expect(
generateVideo(input, { requestId: 'google-processing' })
).rejects.toThrow(error)
await Promise.all([rejection, vi.advanceTimersByTimeAsync(5000)])
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})

it('stops during Files processing without downloading or polling again', async () => {
const controller = new AbortController()
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(completed()))
.mockResolvedValueOnce(jsonResponse({ state: 'PROCESSING' }))
vi.stubGlobal('fetch', fetchMock)
const result = generateVideo(input, {
requestId: 'google-stopped',
signal: controller.signal,
})
const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
await Promise.all([
rejection,
vi.advanceTimersByTimeAsync(0).then(() => {
controller.abort(new DOMException('Stopped', 'AbortError'))
}),
])
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})

it.each([
'https://foreign.example/v1beta/files/video-file:download?alt=media',
'https://generativelanguage.googleapis.com/v1beta/models/model:download?alt=media',
'https://generativelanguage.googleapis.com/v1beta/files/nested%2Ffile:download?alt=media',
])('rejects a non-Files download URI before sending credentials: %s', async (uri) => {
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(completed(uri)))
vi.stubGlobal('fetch', fetchMock)
await expect(generateVideo(input, { requestId: 'google-invalid-uri' })).rejects.toThrow(
/Invalid Google video file URI/
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})

it('keeps Files metadata within the ordinary JSON response limit', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(jsonResponse(completed()))
.mockResolvedValueOnce(
jsonResponse(
{ state: 'ACTIVE' },
{ headers: { 'Content-Length': String(2 * 1024 * 1024 + 1) } }
)
)
)
await expect(
generateVideo(input, { requestId: 'google-metadata-limit' })
).rejects.toMatchObject({
name: 'PayloadSizeLimitError',
})
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})

it('does not poll a full interaction when synchronous generation does not complete', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() =>
Promise.resolve(jsonResponse({ id: 'interaction-1', status: 'in_progress' }))
)
vi.stubGlobal('fetch', fetchMock)
const rejection = expect(
generateVideo(input, { requestId: 'google-incomplete' })
).rejects.toThrow(/Google synchronous video generation did not complete/)
await Promise.all([rejection, vi.advanceTimersByTimeAsync(5000)])
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})

it('reports a failed MiniMax job immediately instead of polling until timeout', async () => {
const fetchMock = vi
.fn()
Expand Down
29 changes: 25 additions & 4 deletions apps/sim/lib/internal/video/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,29 @@ async function generateWithDirectProvider(
}
}
if (!buffer && videoUrl) {
if (
protocol === 'gemini' &&
new URL(videoUrl).origin === 'https://generativelanguage.googleapis.com'
) {
if (protocol === 'gemini') {
const fileUrl = new URL(videoUrl)
const fileId = fileUrl.pathname.match(/^\/v1beta\/files\/([a-zA-Z0-9_-]+):download$/)?.[1]
if (
fileUrl.origin !== 'https://generativelanguage.googleapis.com' ||
fileUrl.username ||
fileUrl.password ||
fileUrl.hash ||
!fileId
)
throw new Error('Invalid Google video file URI')
const metadataUrl = `https://generativelanguage.googleapis.com/v1beta/files/${fileId}`
for (let fileAttempt = 0; ; fileAttempt++) {
const file = await readProviderResponse(
await fetch(metadataUrl, { headers, redirect: 'error', signal })
)
const state = getStringProperty(file, 'state')
if (state === 'ACTIVE') break
if (state === 'FAILED') throw new Error('Google video processing failed')
if (fileAttempt === maxAttempts) throw new Error('Google video processing timed out')
await waitForProvider(context)
}
signal?.throwIfAborted()
const validation = await validateUrlWithDNS(videoUrl, 'videoUrl', 'contentFetch', {
signal,
})
Expand Down Expand Up @@ -297,6 +316,8 @@ async function generateWithDirectProvider(
`${input.provider} generation failed: ${getStringProperty(task, 'failure_reason') || getStringProperty(task, 'failure') || getStringProperty(task, 'error') || 'Request failed'}`
)
}
if (protocol === 'gemini')
throw new Error('Google synchronous video generation did not complete')
if (attempt === maxAttempts) break
await waitForProvider(context)
data = await readProviderResponse(await fetch(pollUrl, { headers, redirect: 'error', signal }))
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/lib/internal/video/direct-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export function buildDirectVideoRequest(input: VideoGenerationInput) {
throw new Error(
`${model.label} ${input.visualReference ? 'does not accept a reference image' : 'requires a reference image'}`
)
if (model.protocol === 'minimax-v2' && input.endpoint)
throw new Error(
`${model.label} does not support quality endpoints; select a resolution instead`
)
const maxLength = spec.maxPromptLength ?? 2000
if (input.prompt.length < 3 || input.prompt.length > maxLength)
throw new Error(`Prompt must be between 3 and ${maxLength} characters for ${model.label}`)
Expand Down Expand Up @@ -95,7 +99,9 @@ export function buildDirectVideoRequest(input: VideoGenerationInput) {
body = {
model: model.id,
input: input.prompt,
background: true,
background: false,
store: false,
stream: false,
response_format: {
type: 'video',
delivery: 'uri',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-metadata.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion apps/sim/tools/image/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export const imageGenerateTool: ImageGenerationToolConfig = {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Text prompt describing the image to generate',
description:
'Text prompt describing the image to generate, up to 4,000 characters. Some models have a lower limit.',
},
size: {
type: 'string',
Expand Down
Loading
Loading