diff --git a/src/core.ts b/src/core.ts index 70b8e679c..eafddc517 100644 --- a/src/core.ts +++ b/src/core.ts @@ -301,18 +301,7 @@ export abstract class APIClient { headers[this.idempotencyHeader] = options.idempotencyKey; } - const reqHeaders: Record = { - ...(contentLength && { 'Content-Length': contentLength }), - ...this.defaultHeaders(options), - ...headers, - }; - // let builtin fetch set the Content-Type for multipart bodies - if (isMultipartBody(options.body) && shimsKind !== 'node') { - delete reqHeaders['Content-Type']; - } - - // Strip any headers being explicitly omitted with null - Object.keys(reqHeaders).forEach((key) => reqHeaders[key] === null && delete reqHeaders[key]); + const reqHeaders = this.buildHeaders({ options, headers, contentLength }); const req: RequestInit = { method, @@ -324,9 +313,35 @@ export abstract class APIClient { signal: options.signal ?? null, }; + return { req, url, timeout }; + } + + private buildHeaders({ + options, + headers, + contentLength, + }: { + options: FinalRequestOptions; + headers: Record; + contentLength: string | null | undefined; + }): Record { + const reqHeaders: Record = {}; + if (contentLength) { + reqHeaders['content-length'] = contentLength; + } + + const defaultHeaders = this.defaultHeaders(options); + applyHeadersMut(reqHeaders, defaultHeaders); + applyHeadersMut(reqHeaders, headers); + + // let builtin fetch set the Content-Type for multipart bodies + if (isMultipartBody(options.body) && shimsKind !== 'node') { + delete reqHeaders['content-type']; + } + this.validateHeaders(reqHeaders, headers); - return { req, url, timeout }; + return reqHeaders; } /** @@ -1013,6 +1028,28 @@ export function hasOwn(obj: Object, key: string): boolean { return Object.prototype.hasOwnProperty.call(obj, key); } +/** + * Copies headers from "newHeaders" onto "targetHeaders", + * using lower-case for all properties, + * ignoring any keys with undefined values, + * and deleting any keys with null values. + */ +function applyHeadersMut(targetHeaders: Headers, newHeaders: Headers): void { + for (const k in newHeaders) { + if (!hasOwn(newHeaders, k)) continue; + const lowerKey = k.toLowerCase(); + if (!lowerKey) continue; + + const val = newHeaders[k]; + + if (val === null) { + delete targetHeaders[lowerKey]; + } else if (val !== undefined) { + targetHeaders[lowerKey] = val; + } + } +} + export function debug(action: string, ...args: any[]) { if (typeof process !== 'undefined' && process.env['DEBUG'] === 'true') { console.log(`OpenAI:DEBUG:${action}`, ...args); diff --git a/tests/index.test.ts b/tests/index.test.ts index 538f7dfc9..e056d3b85 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -28,25 +28,25 @@ describe('instantiate client', () => { test('they are used in the request', () => { const { req } = client.buildRequest({ path: '/foo', method: 'post' }); - expect((req.headers as Headers)['X-My-Default-Header']).toEqual('2'); + expect((req.headers as Headers)['x-my-default-header']).toEqual('2'); }); - test('can be overriden with `undefined`', () => { + test('can ignore `undefined` and leave the default', () => { const { req } = client.buildRequest({ path: '/foo', method: 'post', headers: { 'X-My-Default-Header': undefined }, }); - expect((req.headers as Headers)['X-My-Default-Header']).toBeUndefined(); + expect((req.headers as Headers)['x-my-default-header']).toEqual('2'); }); - test('can be overriden with `null`', () => { + test('can be removed with `null`', () => { const { req } = client.buildRequest({ path: '/foo', method: 'post', headers: { 'X-My-Default-Header': null }, }); - expect((req.headers as Headers)['X-My-Default-Header']).toBeUndefined(); + expect(req.headers as Headers).not.toHaveProperty('x-my-default-header'); }); }); @@ -179,12 +179,27 @@ describe('request building', () => { describe('Content-Length', () => { test('handles multi-byte characters', () => { const { req } = client.buildRequest({ path: '/foo', method: 'post', body: { value: '—' } }); - expect((req.headers as Record)['Content-Length']).toEqual('20'); + expect((req.headers as Record)['content-length']).toEqual('20'); }); test('handles standard characters', () => { const { req } = client.buildRequest({ path: '/foo', method: 'post', body: { value: 'hello' } }); - expect((req.headers as Record)['Content-Length']).toEqual('22'); + expect((req.headers as Record)['content-length']).toEqual('22'); + }); + }); + + describe('custom headers', () => { + test('handles undefined', () => { + const { req } = client.buildRequest({ + path: '/foo', + method: 'post', + body: { value: 'hello' }, + headers: { 'X-Foo': 'baz', 'x-foo': 'bar', 'x-Foo': undefined, 'x-baz': 'bam', 'X-Baz': null }, + }); + expect((req.headers as Record)['x-foo']).toEqual('bar'); + expect((req.headers as Record)['x-Foo']).toEqual(undefined); + expect((req.headers as Record)['X-Foo']).toEqual(undefined); + expect((req.headers as Record)['x-baz']).toEqual(undefined); }); }); });