Skip to content

Commit fcebbb2

Browse files
authored
chore(release): merge staging into the v0.9.43 release
2 parents 835252a + 8ec7514 commit fcebbb2

10 files changed

Lines changed: 357 additions & 35 deletions

File tree

‎apps/sim/blocks/blocks/image_generator.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,10 @@ export const ImageGeneratorV2Block: BlockConfig = {
459459
},
460460
inputs: {
461461
provider: { type: 'string', description: 'Image generation provider' },
462-
prompt: { type: 'string', description: 'Image description prompt' },
462+
prompt: {
463+
type: 'string',
464+
description: 'Image description, up to 4,000 characters. Some models have a lower limit.',
465+
},
463466
model: { type: 'string', description: 'Image generation model' },
464467
size: { type: 'string', description: 'Image size' },
465468
aspectRatio: { type: 'string', description: 'Image aspect ratio' },

‎apps/sim/blocks/blocks/video_generator.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1834,7 +1834,7 @@ export const VideoGeneratorV2Block: BlockConfig = {
18341834
const model = params.provider === 'falai' ? getFalVideoModel(params.model) : undefined
18351835
const useFalFields = model && !model.legacyId
18361836
const setting = (legacy: string, catalog: string) => {
1837-
const value = params[useFalFields ? catalog : legacy]
1837+
const value = useFalFields ? (params[catalog] ?? params[legacy]) : params[legacy]
18381838
return useFalFields && value === FAL_MODEL_DEFAULT.id ? undefined : value
18391839
}
18401840
const duration = setting('duration', 'falDuration')
@@ -1956,7 +1956,7 @@ export const VideoGeneratorV3Block: BlockConfig = {
19561956
promptOptimizer: parseOptionalBooleanInput(
19571957
setting('promptOptimizer', model?.settings?.promptOptimizer)
19581958
),
1959-
endpoint: model?.protocol === 'minimax-v1' ? params.endpoint : undefined,
1959+
endpoint: params.provider === 'minimax' ? params.endpoint : undefined,
19601960
cameraControl:
19611961
model?.protocol === 'luma-legacy' && params.cameraControl
19621962
? typeof params.cameraControl === 'string'

‎apps/sim/lib/internal/image/request.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { ImageGenerationInput, ImageProvider } from '@/lib/internal/image/schema'
22
import { getImageModel, imageModelDefaults, imageOptionFields } from '@/lib/media/image-catalog'
33

4+
/** Sim limits image prompts independently of larger provider model allowances. */
5+
const MAX_IMAGE_PROMPT_CHARACTERS = 4000
6+
47
/** Builds only supported settings for the selected image model before a paid request is sent. */
58
export function buildImageGenerationRequest(input: ImageGenerationInput) {
69
const model = getImageModel(
@@ -12,7 +15,10 @@ export function buildImageGenerationRequest(input: ImageGenerationInput) {
1215
throw new Error('Image generation returns one image per tool execution; numImages must be 1')
1316
}
1417
const minLength = Math.max(3, model.minPromptLength ?? 3)
15-
const maxLength = Math.min(4000, model.maxPromptLength ?? 4000)
18+
const maxLength = Math.min(
19+
MAX_IMAGE_PROMPT_CHARACTERS,
20+
model.maxPromptLength ?? MAX_IMAGE_PROMPT_CHARACTERS
21+
)
1622
if (input.prompt.length < minLength || input.prompt.length > maxLength) {
1723
throw new Error(
1824
`Prompt must be between ${minLength} and ${maxLength} characters for ${model.label}`

‎apps/sim/lib/internal/video/client.test.ts‎

Lines changed: 153 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ describe('Video provider client', () => {
2828
isValid: true,
2929
resolvedIP: '203.0.113.1',
3030
})
31-
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
32-
new Response(Buffer.from('video'))
33-
)
31+
inputValidationMockFns.mockSecureFetchWithPinnedIP
32+
.mockReset()
33+
.mockResolvedValue(new Response(Buffer.from('video')))
3434
})
3535

3636
afterEach(() => {
@@ -204,23 +204,6 @@ describe('Video provider client', () => {
204204
},
205205
},
206206
},
207-
{
208-
provider: 'veo' as const,
209-
model: 'gemini-omni-1.1-flash',
210-
submit: '/v1beta/interactions',
211-
poll: '/v1beta/interactions/job',
212-
payload: { model: 'gemini-omni-1.1-flash', input: 'A cinematic sunrise' },
213-
created: { id: 'job', status: 'in_progress' },
214-
completed: {
215-
status: 'completed',
216-
steps: [
217-
{
218-
type: 'model_output',
219-
content: [{ type: 'video', mime_type: 'video/mp4', data: 'dmlkZW8=' }],
220-
},
221-
],
222-
},
223-
},
224207
])('executes $model through its native submit, poll and result protocol', async (testCase) => {
225208
let submissions = 0
226209
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
@@ -251,6 +234,156 @@ describe('Video provider client', () => {
251234
expect(submissions).toBe(1)
252235
})
253236

237+
describe('Gemini URI delivery', () => {
238+
const input = { provider: 'veo' as const, apiKey: 'key', prompt: 'A cinematic sunrise' }
239+
const videoUri =
240+
'https://generativelanguage.googleapis.com/v1beta/files/video-file:download?alt=media'
241+
const completed = (uri = videoUri) => ({
242+
id: 'interaction-1',
243+
status: 'completed',
244+
steps: [{ type: 'model_output', content: [{ type: 'video', uri }] }],
245+
})
246+
247+
it('downloads a normal-sized video after Files becomes ACTIVE without reading inline polling data', async () => {
248+
const video = Buffer.alloc(2 * 1024 * 1024)
249+
let filePolls = 0
250+
let submissions = 0
251+
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
252+
const path = new URL(url).pathname
253+
if (path === '/v1beta/interactions' && init?.method === 'POST') {
254+
submissions++
255+
const body = JSON.parse(String(init.body))
256+
return jsonResponse(
257+
body.background ? { id: 'interaction-1', status: 'in_progress' } : completed()
258+
)
259+
}
260+
if (path === '/v1beta/interactions/interaction-1')
261+
return jsonResponse({
262+
...completed(),
263+
steps: [
264+
{
265+
type: 'model_output',
266+
content: [{ type: 'video', data: video.toString('base64') }],
267+
},
268+
],
269+
})
270+
if (path === '/v1beta/files/video-file') {
271+
filePolls++
272+
expect(new Headers(init?.headers).get('x-goog-api-key')).toBe('key')
273+
return jsonResponse({ state: filePolls === 1 ? 'PROCESSING' : 'ACTIVE' })
274+
}
275+
return new Response(null, { status: 404 })
276+
})
277+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockImplementationOnce(
278+
async (_url, _ip, options) => {
279+
expect(new Headers(options.headers).get('x-goog-api-key')).toBe('key')
280+
return filePolls >= 2
281+
? new Response(video)
282+
: new Response('File is not ACTIVE', { status: 409 })
283+
}
284+
)
285+
const result = generateVideo(input, { requestId: 'google-uri' })
286+
const completion = result.then((output) => {
287+
expect(output.buffer.equals(video)).toBe(true)
288+
expect(output.jobId).toBe('interaction-1')
289+
})
290+
await Promise.all([
291+
completion,
292+
vi.advanceTimersByTimeAsync(0).then(() => vi.advanceTimersByTimeAsync(5000)),
293+
])
294+
expect(submissions).toBe(1)
295+
})
296+
297+
it.each([
298+
{ state: 'FAILED', error: /Google video processing failed/ },
299+
{ state: 'PROCESSING', error: /Google video processing timed out/ },
300+
])('does not download a file that remains $state', async ({ state, error }) => {
301+
vi.stubGlobal(
302+
'fetch',
303+
vi
304+
.fn()
305+
.mockResolvedValueOnce(jsonResponse(completed()))
306+
.mockImplementation(() => Promise.resolve(jsonResponse({ state })))
307+
)
308+
const rejection = expect(
309+
generateVideo(input, { requestId: 'google-processing' })
310+
).rejects.toThrow(error)
311+
await Promise.all([rejection, vi.advanceTimersByTimeAsync(5000)])
312+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
313+
})
314+
315+
it('stops during Files processing without downloading or polling again', async () => {
316+
const controller = new AbortController()
317+
const fetchMock = vi
318+
.fn()
319+
.mockResolvedValueOnce(jsonResponse(completed()))
320+
.mockResolvedValueOnce(jsonResponse({ state: 'PROCESSING' }))
321+
vi.stubGlobal('fetch', fetchMock)
322+
const result = generateVideo(input, {
323+
requestId: 'google-stopped',
324+
signal: controller.signal,
325+
})
326+
const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
327+
await Promise.all([
328+
rejection,
329+
vi.advanceTimersByTimeAsync(0).then(() => {
330+
controller.abort(new DOMException('Stopped', 'AbortError'))
331+
}),
332+
])
333+
expect(fetchMock).toHaveBeenCalledTimes(2)
334+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
335+
})
336+
337+
it.each([
338+
'https://foreign.example/v1beta/files/video-file:download?alt=media',
339+
'https://generativelanguage.googleapis.com/v1beta/models/model:download?alt=media',
340+
'https://generativelanguage.googleapis.com/v1beta/files/nested%2Ffile:download?alt=media',
341+
])('rejects a non-Files download URI before sending credentials: %s', async (uri) => {
342+
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(completed(uri)))
343+
vi.stubGlobal('fetch', fetchMock)
344+
await expect(generateVideo(input, { requestId: 'google-invalid-uri' })).rejects.toThrow(
345+
/Invalid Google video file URI/
346+
)
347+
expect(fetchMock).toHaveBeenCalledTimes(1)
348+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
349+
})
350+
351+
it('keeps Files metadata within the ordinary JSON response limit', async () => {
352+
vi.stubGlobal(
353+
'fetch',
354+
vi
355+
.fn()
356+
.mockResolvedValueOnce(jsonResponse(completed()))
357+
.mockResolvedValueOnce(
358+
jsonResponse(
359+
{ state: 'ACTIVE' },
360+
{ headers: { 'Content-Length': String(2 * 1024 * 1024 + 1) } }
361+
)
362+
)
363+
)
364+
await expect(
365+
generateVideo(input, { requestId: 'google-metadata-limit' })
366+
).rejects.toMatchObject({
367+
name: 'PayloadSizeLimitError',
368+
})
369+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
370+
})
371+
372+
it('does not poll a full interaction when synchronous generation does not complete', async () => {
373+
const fetchMock = vi
374+
.fn()
375+
.mockImplementation(() =>
376+
Promise.resolve(jsonResponse({ id: 'interaction-1', status: 'in_progress' }))
377+
)
378+
vi.stubGlobal('fetch', fetchMock)
379+
const rejection = expect(
380+
generateVideo(input, { requestId: 'google-incomplete' })
381+
).rejects.toThrow(/Google synchronous video generation did not complete/)
382+
await Promise.all([rejection, vi.advanceTimersByTimeAsync(5000)])
383+
expect(fetchMock).toHaveBeenCalledTimes(1)
384+
})
385+
})
386+
254387
it('reports a failed MiniMax job immediately instead of polling until timeout', async () => {
255388
const fetchMock = vi
256389
.fn()

‎apps/sim/lib/internal/video/client.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,29 @@ async function generateWithDirectProvider(
227227
}
228228
}
229229
if (!buffer && videoUrl) {
230-
if (
231-
protocol === 'gemini' &&
232-
new URL(videoUrl).origin === 'https://generativelanguage.googleapis.com'
233-
) {
230+
if (protocol === 'gemini') {
231+
const fileUrl = new URL(videoUrl)
232+
const fileId = fileUrl.pathname.match(/^\/v1beta\/files\/([a-zA-Z0-9_-]+):download$/)?.[1]
233+
if (
234+
fileUrl.origin !== 'https://generativelanguage.googleapis.com' ||
235+
fileUrl.username ||
236+
fileUrl.password ||
237+
fileUrl.hash ||
238+
!fileId
239+
)
240+
throw new Error('Invalid Google video file URI')
241+
const metadataUrl = `https://generativelanguage.googleapis.com/v1beta/files/${fileId}`
242+
for (let fileAttempt = 0; ; fileAttempt++) {
243+
const file = await readProviderResponse(
244+
await fetch(metadataUrl, { headers, redirect: 'error', signal })
245+
)
246+
const state = getStringProperty(file, 'state')
247+
if (state === 'ACTIVE') break
248+
if (state === 'FAILED') throw new Error('Google video processing failed')
249+
if (fileAttempt === maxAttempts) throw new Error('Google video processing timed out')
250+
await waitForProvider(context)
251+
}
252+
signal?.throwIfAborted()
234253
const validation = await validateUrlWithDNS(videoUrl, 'videoUrl', 'contentFetch', {
235254
signal,
236255
})
@@ -297,6 +316,8 @@ async function generateWithDirectProvider(
297316
`${input.provider} generation failed: ${getStringProperty(task, 'failure_reason') || getStringProperty(task, 'failure') || getStringProperty(task, 'error') || 'Request failed'}`
298317
)
299318
}
319+
if (protocol === 'gemini')
320+
throw new Error('Google synchronous video generation did not complete')
300321
if (attempt === maxAttempts) break
301322
await waitForProvider(context)
302323
data = await readProviderResponse(await fetch(pollUrl, { headers, redirect: 'error', signal }))

‎apps/sim/lib/internal/video/direct-request.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export function buildDirectVideoRequest(input: VideoGenerationInput) {
1515
throw new Error(
1616
`${model.label} ${input.visualReference ? 'does not accept a reference image' : 'requires a reference image'}`
1717
)
18+
if (model.protocol === 'minimax-v2' && input.endpoint)
19+
throw new Error(
20+
`${model.label} does not support quality endpoints; select a resolution instead`
21+
)
1822
const maxLength = spec.maxPromptLength ?? 2000
1923
if (input.prompt.length < 3 || input.prompt.length > maxLength)
2024
throw new Error(`Prompt must be between 3 and ${maxLength} characters for ${model.label}`)
@@ -95,7 +99,9 @@ export function buildDirectVideoRequest(input: VideoGenerationInput) {
9599
body = {
96100
model: model.id,
97101
input: input.prompt,
98-
background: true,
102+
background: false,
103+
store: false,
104+
stream: false,
99105
response_format: {
100106
type: 'video',
101107
delivery: 'uri',

‎apps/sim/tools/generated/tool-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎apps/sim/tools/image/generate.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export const imageGenerateTool: ImageGenerationToolConfig = {
3535
type: 'string',
3636
required: true,
3737
visibility: 'user-or-llm',
38-
description: 'Text prompt describing the image to generate',
38+
description:
39+
'Text prompt describing the image to generate, up to 4,000 characters. Some models have a lower limit.',
3940
},
4041
size: {
4142
type: 'string',

0 commit comments

Comments
 (0)