diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts index 6b3709dec..52b46a806 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts @@ -191,16 +191,33 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => { ).rejects.toMatchObject({ statusCode: 401 }); }); - it('throws 400 when the requested model is unknown', async () => { - await expect( - withTestActor(() => - driver.complete({ - model: 'totally-not-a-model', - messages: [{ role: 'user', content: 'hi' }], - }), - ), - ).rejects.toMatchObject({ statusCode: 400 }); - }); + it.each(['totally-not-a-model', '__proto__', 'constructor'])( + 'throws 400 when the requested model is unknown: %s', + async (model) => { + await expect( + withTestActor(() => + driver.complete({ + model, + messages: [{ role: 'user', content: 'hi' }], + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }, + ); + + it.each(['__proto__', 'constructor'])( + 'rejects inherited object keys as unknown providers: %s', + async (provider) => { + await expect( + withTestActor(() => + driver.complete({ + provider, + messages: [{ role: 'user', content: 'hi' }], + } as ICompleteArguments), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }, + ); it('falls back to the provider default model when neither model nor provider is given (azure-openai is the hard-coded default provider)', async () => { // Without `azure-openai` in providers config, the driver tries diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 33053c5d0..2e4136109 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -301,8 +301,8 @@ export class ChatCompletionDriver extends PuterDriver { readonly rateLimit = AI_RATE_LIMIT; readonly concurrent = AI_CONCURRENT; - #providers: Record = {}; - #modelIdMap: Record = {}; + #providers: Record = Object.create(null); + #modelIdMap: Record = Object.create(null); /** Metering scoped to this driver. Lazy: services wire up after drivers. */ get #aiMetering(): MeteringService { diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts index c8f6f9c69..070086776 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts @@ -251,6 +251,33 @@ describe('VideoGenerationDriver catalog', () => { // ── Provider routing ──────────────────────────────────────────────── describe('VideoGenerationDriver.generate provider routing', () => { + it.each(['__proto__', 'constructor'])( + 'rejects inherited object keys as unknown models: %s', + async (model) => { + await expect( + withDriverName('ai-video', () => + driver.generate({ prompt: 'hi', model }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + }); + expect(geminiGenerateVideosMock).not.toHaveBeenCalled(); + expect(togetherVideosCreateMock).not.toHaveBeenCalled(); + }, + ); + + it.each(['__proto__', 'constructor'])( + 'uses the default for an unknown provider named %s', + async (provider) => { + geminiGenerateVideosMock.mockResolvedValueOnce( + geminiCompletedOperation(), + ); + await withActor(() => driver.generate({ prompt: 'hi', provider })); + expect(geminiSent().model).toBe(DEFAULT_MODEL); + }, + ); + it('routes a known veo-3.1-generate-preview id to the Gemini provider', async () => { geminiGenerateVideosMock.mockResolvedValueOnce( geminiCompletedOperation(), diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.ts index 3a1939e70..d4ff10dde 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.ts @@ -92,8 +92,8 @@ export class VideoGenerationDriver extends PuterDriver { readonly rateLimit = AI_RATE_LIMIT; readonly concurrent = AI_CONCURRENT; - #providers: Record = {}; - #modelIdMap: Record = {}; + #providers: Record = Object.create(null); + #modelIdMap: Record = Object.create(null); /** Metering scoped to this driver. Lazy: services wire up after drivers. */ get #aiMetering(): MeteringService { diff --git a/src/docs/src/AI/txt2vid.md b/src/docs/src/AI/txt2vid.md index 1646b3b25..1c9b04e3c 100644 --- a/src/docs/src/AI/txt2vid.md +++ b/src/docs/src/AI/txt2vid.md @@ -18,7 +18,7 @@ puter.ai.txt2vid({prompt, ...options}) #### `prompt` (String) (required) -The text description that guides the video generation. Describe the subject, the motion, the camera move and the mood; cues such as "slow motion", "aerial shot" or "handheld" are understood by most models. +A non-empty string describing the video. Missing, blank, or non-string prompts reject with `prompt_required`. Describe the subject, the motion, the camera move and the mood; cues such as "slow motion", "aerial shot" or "handheld" are understood by most models. #### `testMode` (Boolean) (optional) @@ -28,7 +28,7 @@ Test mode still resolves the `model` you asked for and still validates (and writ #### `options` (Object) (optional) -Additional settings for the generation request. The options below carry the same meaning on every provider; each provider then accepts a few extras, listed in the sections that follow. Any option a provider does not recognize is ignored. +Additional settings for the generation request. Puter copies this object before resolving aliases and output paths; frozen options are supported. The options below carry the same meaning on every provider; each provider then accepts a few extras, listed in the sections that follow. Any option a provider does not recognize is ignored. | Option | Type | Description | |--------|------|-------------| diff --git a/src/puter-js/src/modules/ai/video.js b/src/puter-js/src/modules/ai/video.js index f5672fe5a..071b90bb2 100644 --- a/src/puter-js/src/modules/ai/video.js +++ b/src/puter-js/src/modules/ai/video.js @@ -49,16 +49,16 @@ export async function txt2vid (promptOrOptions, optionsOrTestMode) { testMode = true; } - if ( typeof promptOrOptions === 'string' && typeof optionsOrTestMode === 'object' ) { - options = optionsOrTestMode; + if ( typeof promptOrOptions === 'string' && optionsOrTestMode && typeof optionsOrTestMode === 'object' ) { + options = { ...optionsOrTestMode }; options.prompt = promptOrOptions; } - if ( typeof promptOrOptions === 'object' ) { - options = promptOrOptions; + if ( promptOrOptions && typeof promptOrOptions === 'object' ) { + options = { ...promptOrOptions }; } - if ( ! options.prompt ) { + if ( typeof options.prompt !== 'string' || ! options.prompt.trim() ) { throw ({ message: 'Prompt parameter is required', code: 'prompt_required' }); } diff --git a/src/puter-js/tests/api/suites/ai.suite.ts b/src/puter-js/tests/api/suites/ai.suite.ts index cc45172f5..dbac843aa 100644 --- a/src/puter-js/tests/api/suites/ai.suite.ts +++ b/src/puter-js/tests/api/suites/ai.suite.ts @@ -902,6 +902,25 @@ export default suite('ai', { } }, + 'txt2vid rejects absent or invalid prompts consistently': async (t) => { + useApiToken(t); + for (const input of [undefined, null, {}, '', ' ', { prompt: 1 }]) { + const error = await errorOf(t, () => t.puter.ai.txt2vid(input)); + t.assert.equal(error.code, 'prompt_required'); + } + }, + + 'txt2vid accepts frozen options without mutating aliases or paths': async (t) => { + useApiToken(t); + const options = Object.freeze({ duration: 4, puter_output_path: 'video.mp4' }); + const positional = await errorOf(t, () => t.puter.ai.txt2vid('a landscape', options)); + t.assert.equal(positional.code, 'internal_error'); + const objectForm = await errorOf(t, () => t.puter.ai.txt2vid(Object.freeze({ ...options, prompt: 'a landscape' }))); + t.assert.equal(objectForm.code, 'internal_error'); + t.assert.equal(options.puter_output_path, 'video.mp4'); + t.assert.equal(Object.hasOwn(options, 'seconds'), false); + }, + 'txt2vid takes duration as an alias of seconds': async (t) => { useApiToken(t); // `duration` has to be mapped before the request leaves the SDK; if