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
28 changes: 21 additions & 7 deletions src/internal/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,21 @@ export const isAsyncIterable = (value: any): value is AsyncIterable<any> =>
export const maybeMultipartFormRequestOptions = async (
opts: RequestOptions,
fetch: OpenAI | Fetch,
{ stripFilenames = true }: { stripFilenames?: boolean } = {},
): Promise<RequestOptions> => {
if (!hasUploadableValue(opts.body)) return opts;

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

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

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

const supportsFormDataMap = /* @__PURE__ */ new WeakMap<Fetch, Promise<boolean>>();
Expand Down Expand Up @@ -125,14 +127,17 @@ function supportsFormData(fetchObject: OpenAI | Fetch): Promise<boolean> {
export const createForm = async <T = Record<string, unknown>>(
body: T | undefined,
fetch: OpenAI | Fetch,
{ stripFilenames = true }: { stripFilenames?: boolean } = {},
): 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, { stripFilenames })),
);
return form;
};

Expand All @@ -156,7 +161,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,
{ stripFilenames = true }: { stripFilenames?: boolean } = {},
): Promise<void> => {
if (value === undefined) return;
if (value == null) {
throw new TypeError(
Expand All @@ -172,12 +182,16 @@ const addFormValue = async (form: FormData, key: string, value: unknown): Promis
} else if (isAsyncIterable(value)) {
form.append(key, makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value)));
} else if (isNamedBlob(value)) {
form.append(key, value, getName(value));
form.append(
key,
value,
stripFilenames ? getName(value) : (('name' in value && value.name && String(value.name)) || getName(value)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve stream paths when filename stripping is disabled

The new stripFilenames option is only applied in the isNamedBlob branch, but fs.ReadStream uploads (an allowed Uploadable) hit the isAsyncIterable branch above and still use getName(value), which drops directory components. That means skills.create() and skills.versions.create() still flatten paths like my-skill/SKILL.md to SKILL.md when callers pass streams, so directory uploads remain broken for a common Node usage pattern.

Useful? React with 👍 / 👎.

);
} 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, { stripFilenames })));
} 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, { stripFilenames })),
);
} else {
throw new TypeError(
Expand Down
2 changes: 1 addition & 1 deletion src/resources/responses/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5935,7 +5935,7 @@ export interface ResponseRefusalDoneEvent {
export type ResponseStatus = 'completed' | 'failed' | 'in_progress' | 'cancelled' | 'queued' | 'incomplete';

/**
* Emitted when there is a partial audio response.
* A streamed event emitted by the Responses API.
*/
export type ResponseStreamEvent =
| ResponseAudioDeltaEvent
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
21 changes: 21 additions & 0 deletions tests/api-resources/skills/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import OpenAI, { toFile } from 'openai';
import { mockFetch } from '../../utils/mock-fetch';
import { File } from 'node:buffer';

const client = new OpenAI({
apiKey: 'My API Key',
Expand Down Expand Up @@ -29,6 +31,25 @@ describe('resource skills', () => {
).rejects.toThrow(OpenAI.NotFoundError);
});

test('create: preserves nested skill file paths', async () => {
const { fetch, handleRequest } = mockFetch();
const client = new OpenAI({ apiKey: 'My API Key', fetch });

handleRequest(async (_, init) => {
const files = (init!.body as FormData).getAll('files[]');
expect(files).toHaveLength(1);
expect(files[0]).toBeInstanceOf(File);
expect((files[0] as File).name).toEqual('my-skill/SKILL.md');

return new Response(JSON.stringify({ id: 'skill_123', created_at: 0, default_version: '1', description: '', latest_version: '1', name: 'my-skill', object: 'skill' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});

await client.skills.create({ files: [new File(['# skill'], 'my-skill/SKILL.md')] });
});

test('retrieve', async () => {
const responsePromise = client.skills.retrieve('skill_123');
const rawResponse = await responsePromise.asResponse();
Expand Down
32 changes: 32 additions & 0 deletions tests/api-resources/skills/versions/versions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import OpenAI, { toFile } from 'openai';
import { mockFetch } from '../../../utils/mock-fetch';
import { File } from 'node:buffer';

const client = new OpenAI({
apiKey: 'My API Key',
Expand Down Expand Up @@ -30,6 +32,36 @@ describe('resource versions', () => {
).rejects.toThrow(OpenAI.NotFoundError);
});

test('create: preserves nested skill file paths', async () => {
const { fetch, handleRequest } = mockFetch();
const client = new OpenAI({ apiKey: 'My API Key', fetch });

handleRequest(async (_, init) => {
const files = (init!.body as FormData).getAll('files[]');
expect(files).toHaveLength(1);
expect(files[0]).toBeInstanceOf(File);
expect((files[0] as File).name).toEqual('my-skill/SKILL.md');

return new Response(
JSON.stringify({
id: 'skillver_123',
created_at: 0,
description: '',
name: 'my-skill',
object: 'skill.version',
skill_id: 'skill_123',
version: '1',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
});

await client.skills.versions.create('skill_123', { files: [new File(['# skill'], 'my-skill/SKILL.md')] });
});

test('retrieve: only required params', async () => {
const responsePromise = client.skills.versions.retrieve('version', { skill_id: 'skill_123' });
const rawResponse = await responsePromise.asResponse();
Expand Down