mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-01 09:38:21 +00:00
fix: null-prototype model maps in chat/video drivers; txt2vid prompt and option guards (#3887)
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:
co-authored by
Claude Fable 5.1
parent
e2a5ab8cdc
commit
081941a6f3
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|--------|------|-------------|
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user