From 6896b2bbf8c99ed092cfa319cfda43d818bc8874 Mon Sep 17 00:00:00 2001 From: kiwigitops Date: Mon, 22 Jun 2026 01:37:18 -0400 Subject: [PATCH 1/2] fix: preserve skill upload file paths --- src/internal/uploads.ts | 62 ++++++++++++++--------- src/resources/skills/skills.ts | 4 +- src/resources/skills/versions/versions.ts | 4 +- tests/api-resources/skills/skills.test.ts | 38 ++++++++++++++ tests/form.test.ts | 23 +++++++++ 5 files changed, 106 insertions(+), 25 deletions(-) diff --git a/src/internal/uploads.ts b/src/internal/uploads.ts index 15073983b4..753637aef0 100644 --- a/src/internal/uploads.ts +++ b/src/internal/uploads.ts @@ -49,20 +49,23 @@ 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 - ); +type CreateFormOptions = { + preserveFilePaths?: boolean; +}; + +export function getName(value: any, options: CreateFormOptions = {}): string | undefined { + const name = + (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)))) || + ''; + + if (options.preserveFilePaths) return name || undefined; + + return name.split(/[\\/]/).pop() || undefined; } export const isAsyncIterable = (value: any): value is AsyncIterable => @@ -75,10 +78,11 @@ export const isAsyncIterable = (value: any): value is AsyncIterable => export const maybeMultipartFormRequestOptions = async ( opts: RequestOptions, fetch: OpenAI | Fetch, + options?: CreateFormOptions, ): Promise => { if (!hasUploadableValue(opts.body)) return opts; - return { ...opts, body: await createForm(opts.body, fetch) }; + return { ...opts, body: await createForm(opts.body, fetch, options) }; }; type MultipartFormRequestOptions = Omit & { body: unknown }; @@ -86,8 +90,9 @@ type MultipartFormRequestOptions = Omit & { body: unknow export const multipartFormRequestOptions = async ( opts: MultipartFormRequestOptions, fetch: OpenAI | Fetch, + options?: CreateFormOptions, ): Promise => { - return { ...opts, body: await createForm(opts.body, fetch) }; + return { ...opts, body: await createForm(opts.body, fetch, options) }; }; const supportsFormDataMap = /* @__PURE__ */ new WeakMap>(); @@ -125,6 +130,7 @@ function supportsFormData(fetchObject: OpenAI | Fetch): Promise { export const createForm = async >( body: T | undefined, fetch: OpenAI | Fetch, + options?: CreateFormOptions, ): Promise => { if (!(await supportsFormData(fetch))) { throw new TypeError( @@ -132,7 +138,9 @@ export const createForm = async >( ); } 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; }; @@ -156,7 +164,12 @@ const hasUploadableValue = (value: unknown): boolean => { return false; }; -const addFormValue = async (form: FormData, key: string, value: unknown): Promise => { +const addFormValue = async ( + form: FormData, + key: string, + value: unknown, + options: CreateFormOptions = {}, +): Promise => { if (value === undefined) return; if (value == null) { throw new TypeError( @@ -168,16 +181,19 @@ 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, options))); } 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, options)), + ); } else if (isNamedBlob(value)) { - form.append(key, value, getName(value)); + form.append(key, value, getName(value, options)); } 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( diff --git a/src/resources/skills/skills.ts b/src/resources/skills/skills.ts index b6e398e894..0f83b674d2 100644 --- a/src/resources/skills/skills.ts +++ b/src/resources/skills/skills.ts @@ -32,7 +32,9 @@ export class Skills extends APIResource { create(body: SkillCreateParams | null | undefined = {}, options?: RequestOptions): APIPromise { return this._client.post( '/skills', - maybeMultipartFormRequestOptions({ body, ...options, __security: { bearerAuth: true } }, this._client), + maybeMultipartFormRequestOptions({ body, ...options, __security: { bearerAuth: true } }, this._client, { + preserveFilePaths: true, + }), ); } diff --git a/src/resources/skills/versions/versions.ts b/src/resources/skills/versions/versions.ts index e091753bde..c38cd48951 100644 --- a/src/resources/skills/versions/versions.ts +++ b/src/resources/skills/versions/versions.ts @@ -23,7 +23,9 @@ export class Versions extends APIResource { ): APIPromise { return this._client.post( path`/skills/${skillID}/versions`, - maybeMultipartFormRequestOptions({ body, ...options, __security: { bearerAuth: true } }, this._client), + maybeMultipartFormRequestOptions({ body, ...options, __security: { bearerAuth: true } }, this._client, { + preserveFilePaths: true, + }), ); } diff --git a/tests/api-resources/skills/skills.test.ts b/tests/api-resources/skills/skills.test.ts index e24dbf34be..400e378c0d 100644 --- a/tests/api-resources/skills/skills.test.ts +++ b/tests/api-resources/skills/skills.test.ts @@ -1,6 +1,7 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. import OpenAI, { toFile } from 'openai'; +import { mockFetch } from '../../utils/mock-fetch'; const client = new OpenAI({ apiKey: 'My API Key', @@ -30,6 +31,43 @@ describe('resource skills', () => { ).rejects.toThrow(OpenAI.NotFoundError); }); + test('create: preserves directory paths in uploaded filenames', async () => { + const { fetch, handleRequest } = mockFetch(); + const client = new OpenAI({ + apiKey: 'My API Key', + adminAPIKey: 'My Admin API Key', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', + fetch, + }); + + const formDataProbe = handleRequest(async () => new Response('ok')); + const request = handleRequest(async (_req, init) => { + const form = init!.body as FormData; + expect((form.get('files[]') as File).name).toBe('my-skill/SKILL.md'); + + return new Response( + JSON.stringify({ + id: 'skill_123', + created_at: 0, + default_version: 'v1', + description: 'Test skill', + latest_version: 'v1', + name: 'my-skill', + object: 'skill', + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + }); + + const response = await client.skills.create({ + files: [new File(['# Skill'], 'my-skill/SKILL.md')], + }); + await formDataProbe; + await request; + + expect(response.id).toBe('skill_123'); + }); + test('retrieve', async () => { const responsePromise = client.skills.retrieve('skill_123'); const rawResponse = await responsePromise.asResponse(); diff --git a/tests/form.test.ts b/tests/form.test.ts index 08cae4da0d..fb57518046 100644 --- a/tests/form.test.ts +++ b/tests/form.test.ts @@ -42,6 +42,29 @@ describe('form data validation', () => { expect(form.get('bar')).toBe('baz'); }); + test('strips file paths by default', async () => { + const form = await createForm( + { + files: [new File(['Example data'], 'my-skill/SKILL.md')], + }, + fetch, + ); + + expect((form.get('files[]') as File).name).toBe('SKILL.md'); + }); + + test('preserves file paths when requested', async () => { + const form = await createForm( + { + files: [new File(['Example data'], 'my-skill/SKILL.md')], + }, + fetch, + { preserveFilePaths: true }, + ); + + expect((form.get('files[]') as File).name).toBe('my-skill/SKILL.md'); + }); + test('nested undefined property is stripped', async () => { const form = await createForm( { From 243c17f219464a388cba134e51ca3544f89dc45d Mon Sep 17 00:00:00 2001 From: kiwigitops Date: Mon, 29 Jun 2026 22:14:07 -0400 Subject: [PATCH 2/2] fix: strip response URLs from preserved upload names --- src/internal/uploads.ts | 27 +++++++++++++++++---------- tests/form.test.ts | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/internal/uploads.ts b/src/internal/uploads.ts index 753637aef0..9d8edf6f23 100644 --- a/src/internal/uploads.ts +++ b/src/internal/uploads.ts @@ -54,16 +54,23 @@ type CreateFormOptions = { }; export function getName(value: any, options: CreateFormOptions = {}): string | undefined { - const name = - (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)))) || - ''; - - if (options.preserveFilePaths) return name || undefined; + let name = ''; + let isURL = false; + + if (typeof value === 'object' && value !== null) { + if ('name' in value && value.name) { + name = String(value.name); + } else if ('url' in value && value.url) { + name = String(value.url); + isURL = true; + } else if ('filename' in value && value.filename) { + name = String(value.filename); + } else if ('path' in value && value.path) { + name = String(value.path); + } + } + + if (options.preserveFilePaths && !isURL) return name || undefined; return name.split(/[\\/]/).pop() || undefined; } diff --git a/tests/form.test.ts b/tests/form.test.ts index fb57518046..15e9181ec4 100644 --- a/tests/form.test.ts +++ b/tests/form.test.ts @@ -65,6 +65,21 @@ describe('form data validation', () => { expect((form.get('files[]') as File).name).toBe('my-skill/SKILL.md'); }); + test('strips response URLs when preserving file paths', async () => { + const response = new Response('Example data'); + Object.defineProperty(response, 'url', { value: 'https://cdn.example.com/my-skill/SKILL.md' }); + + const form = await createForm( + { + files: [response], + }, + fetch, + { preserveFilePaths: true }, + ); + + expect((form.get('files[]') as File).name).toBe('SKILL.md'); + }); + test('nested undefined property is stripped', async () => { const form = await createForm( {