fix: null-prototype model maps in chat/video drivers; txt2vid prompt and option guards (#3887)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

Chat and video drivers keyed their provider and model maps on plain objects,
so `model` or `provider` values such as `__proto__` reached Object.prototype
and surfaced as 500s. Both maps are now null-prototype objects and reject
those names as ordinary unknown models.

txt2vid now rejects blank or non-string prompts with `prompt_required` before
any request, tolerates `null` in either argument slot, and copies the caller's
options before resolving the `duration` alias and output path so frozen
option objects work and caller objects are never mutated.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
404oops
2026-09-21 13:12:35 -07:00
committed by GitHub
co-authored by Claude Fable 5.1
parent e2a5ab8cdc
commit 081941a6f3
7 changed files with 84 additions and 21 deletions
@@ -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
@@ -301,8 +301,8 @@ export class ChatCompletionDriver extends PuterDriver {
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
#providers: Record<string, IChatProvider> = {};
#modelIdMap: Record<string, IChatModel[]> = {};
#providers: Record<string, IChatProvider> = Object.create(null);
#modelIdMap: Record<string, IChatModel[]> = Object.create(null);
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
@@ -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(),
@@ -92,8 +92,8 @@ export class VideoGenerationDriver extends PuterDriver {
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
#providers: Record<string, IVideoProvider> = {};
#modelIdMap: Record<string, IVideoModel[]> = {};
#providers: Record<string, IVideoProvider> = Object.create(null);
#modelIdMap: Record<string, IVideoModel[]> = Object.create(null);
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
+2 -2
View File
@@ -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 |
|--------|------|-------------|
+5 -5
View File
@@ -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' });
}
+19
View File
@@ -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