Skip to content
Open
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
84 changes: 61 additions & 23 deletions src/internal/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,35 @@ export function makeFile(
return new File(fileBits as any, fileName ?? 'unknown_file', options);
}

export function getName(value: any): string | undefined {
return (
(
(typeof value === 'object' &&
value !== null &&
(('name' in value && value.name && String(value.name)) ||
('url' in value && value.url && String(value.url)) ||
('filename' in value && value.filename && String(value.filename)) ||
('path' in value && value.path && String(value.path)))) ||
''
)
.split(/[\\/]/)
.pop() || undefined
);
function basename(name: string, source: 'url' | 'path' | 'explicit'): string | undefined {
if (source === 'url') {
try {
name = new URL(name).pathname;
} catch {
// Fall back to separator splitting for non-URL values.
}
}
return name.split(/[\\/]/).pop() || undefined;
}

export function getName(value: any, opts?: { stripFilename?: boolean | undefined }): string | undefined {
if (typeof value !== 'object' || value === null) return undefined;

const explicitName =
('name' in value && value.name && String(value.name)) ||
('filename' in value && value.filename && String(value.filename));
if (explicitName) {
if (opts?.stripFilename === false) return explicitName;
return basename(explicitName, 'explicit');
}

const urlName = 'url' in value && value.url && String(value.url);
if (urlName) return basename(urlName, 'url');

const pathName = 'path' in value && value.path && String(value.path);
if (pathName) return basename(pathName, 'path');

return undefined;
}

export const isAsyncIterable = (value: any): value is AsyncIterable<any> =>
Expand All @@ -75,19 +90,21 @@ export const isAsyncIterable = (value: any): value is AsyncIterable<any> =>
export const maybeMultipartFormRequestOptions = async (
opts: RequestOptions,
fetch: OpenAI | Fetch,
formOptions?: CreateFormOptions,
): Promise<RequestOptions> => {
if (!hasUploadableValue(opts.body)) return opts;

return { ...opts, body: await createForm(opts.body, fetch) };
return { ...opts, body: await createForm(opts.body, fetch, formOptions) };
};

type MultipartFormRequestOptions = Omit<RequestOptions, 'body'> & { body: unknown };

export const multipartFormRequestOptions = async (
opts: MultipartFormRequestOptions,
fetch: OpenAI | Fetch,
formOptions?: CreateFormOptions,
): Promise<RequestOptions> => {
return { ...opts, body: await createForm(opts.body, fetch) };
return { ...opts, body: await createForm(opts.body, fetch, formOptions) };
};

const supportsFormDataMap = /* @__PURE__ */ new WeakMap<Fetch, Promise<boolean>>();
Expand Down Expand Up @@ -122,17 +139,24 @@ function supportsFormData(fetchObject: OpenAI | Fetch): Promise<boolean> {
return promise;
}

export type CreateFormOptions = {
stripFilenames?: boolean;
};

export const createForm = async <T = Record<string, unknown>>(
body: T | undefined,
fetch: OpenAI | Fetch,
options: CreateFormOptions = {},
): Promise<FormData> => {
if (!(await supportsFormData(fetch))) {
throw new TypeError(
'The provided fetch function does not support file uploads with the current global FormData class.',
);
}
const form = new FormData();
await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value)));
await Promise.all(
Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value, options)),
);
return form;
};

Expand All @@ -156,7 +180,12 @@ const hasUploadableValue = (value: unknown): boolean => {
return false;
};

const addFormValue = async (form: FormData, key: string, value: unknown): Promise<void> => {
const addFormValue = async (
form: FormData,
key: string,
value: unknown,
options: CreateFormOptions,
): Promise<void> => {
if (value === undefined) return;
if (value == null) {
throw new TypeError(
Expand All @@ -168,16 +197,25 @@ const addFormValue = async (form: FormData, key: string, value: unknown): Promis
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
form.append(key, String(value));
} else if (value instanceof Response) {
form.append(key, makeFile([await value.blob()], getName(value)));
form.append(
key,
makeFile([await value.blob()], getName(value, { stripFilename: options.stripFilenames })),
);
} else if (isAsyncIterable(value)) {
form.append(key, makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value)));
form.append(
key,
makeFile(
[await new Response(ReadableStreamFrom(value)).blob()],
getName(value, { stripFilename: options.stripFilenames }),
),
);
} else if (isNamedBlob(value)) {
form.append(key, value, getName(value));
form.append(key, value, getName(value, { stripFilename: options.stripFilenames }));
} else if (Array.isArray(value)) {
await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry)));
await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry, options)));
} else if (typeof value === 'object') {
await Promise.all(
Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)),
Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop, options)),
);
} else {
throw new TypeError(
Expand Down
5 changes: 4 additions & 1 deletion src/resources/skills/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export class Skills extends APIResource {
* Create a new skill.
*/
create(body: SkillCreateParams | null | undefined = {}, options?: RequestOptions): APIPromise<Skill> {
return this._client.post('/skills', maybeMultipartFormRequestOptions({ body, ...options }, this._client));
return this._client.post(
'/skills',
maybeMultipartFormRequestOptions({ body, ...options }, this._client, { stripFilenames: false }),
);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/resources/skills/versions/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class Versions extends APIResource {
): APIPromise<SkillVersion> {
return this._client.post(
path`/skills/${skillID}/versions`,
maybeMultipartFormRequestOptions({ body, ...options }, this._client),
maybeMultipartFormRequestOptions({ body, ...options }, this._client, { stripFilenames: false }),
);
}

Expand Down
52 changes: 52 additions & 0 deletions tests/form.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { multipartFormRequestOptions, createForm } from 'openai/internal/uploads';
import { toFile } from 'openai/core/uploads';
import fs from 'fs';

describe('form data validation', () => {
test('valid values do not error', async () => {
Expand Down Expand Up @@ -82,4 +83,55 @@ describe('form data validation', () => {
);
expect(Array.from(form2.entries())).toEqual([['bar[]', 'foo']]);
});

test('file names strip path separators by default', async () => {
const form = await createForm(
{
file: new File(['Some content'], 'my-skill/SKILL.md'),
},
fetch,
);

expect((form.get('file') as File).name).toBe('SKILL.md');
});

test('file names can preserve path separators for APIs that require directories', async () => {
const form = await createForm(
{
files: [new File(['Some content'], 'my-skill/SKILL.md')],
},
fetch,
{ stripFilenames: false },
);

expect((form.get('files[]') as File).name).toBe('my-skill/SKILL.md');
});

test('path-preserving mode still strips inferred Response URL filenames', async () => {
class MockResponse extends Response {
override url = 'https://example.com/my-skill/SKILL.md';
}

const form = await createForm(
{
files: [new MockResponse('Some content', { status: 200 })],
},
fetch,
{ stripFilenames: false },
);

expect((form.get('files[]') as File).name).toBe('SKILL.md');
});

test('path-preserving mode still strips inferred ReadStream paths', async () => {
const form = await createForm(
{
files: [fs.createReadStream('tests/uploads.test.ts')],
},
fetch,
{ stripFilenames: false },
);

expect((form.get('files[]') as File).name).toBe('uploads.test.ts');
});
});