diff --git a/README.md b/README.md index b0392fea4..b76fe2b58 100644 --- a/README.md +++ b/README.md @@ -596,7 +596,7 @@ const client = new OpenAI({ fetch }); ### Fetch options -If you want to set custom `fetch` options without overriding the `fetch` function, you can provide a `fetchOptions` object when instantiating the client or making a request. (Request-specific options override client options.) +If you want to set custom `fetch` options without overriding the `fetch` function, you can provide a `fetchOptions` object when instantiating the client or making a request. (Request-specific options override client options.) These options are forwarded to the active `fetch` implementation unchanged, so runtime-specific fields such as Undici's `dispatcher` only work when the underlying `fetch` supports them. ```ts import OpenAI from 'openai'; @@ -652,6 +652,50 @@ const client = new OpenAI({ }); ``` +#### Configuring custom CA certificates + +On Node.js `22.19+` and `24.5+`, the SDK will merge certificates from `NODE_EXTRA_CA_CERTS` into Node's +default TLS CA store before it uses the default global `fetch`. This keeps certificate verification enabled +while allowing requests to trust your additional corporate or private root CAs. + +```sh +export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca-chain.pem +``` + +```ts +import OpenAI from 'openai'; + +const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); +``` + +If you need per-client CA behavior instead of a process-wide default, or you're running in a framework that patches `globalThis.fetch` and ignores Undici-specific options such as `dispatcher`, pass a custom `fetch` implementation: + +```ts +import fs from 'node:fs'; +import tls from 'node:tls'; +import OpenAI from 'openai'; +import { Agent, fetch as undiciFetch } from 'undici'; + +const extraCAPath = process.env.NODE_EXTRA_CA_CERTS; +const extraCA = extraCAPath ? fs.readFileSync(extraCAPath, 'utf8') : undefined; + +const dispatcher = + extraCA ? + new Agent({ + connect: { + // Supplying `ca` replaces the default trust store, so keep Node's roots too. + ca: [...tls.rootCertificates, extraCA], + }, + }) + : undefined; + +const client = new OpenAI({ + fetch: dispatcher ? (url, init) => undiciFetch(url, { ...init, dispatcher }) : undefined, +}); +``` + ## Frequently Asked Questions ## Semantic versioning diff --git a/src/client.ts b/src/client.ts index 65ddc114c..509a3b373 100644 --- a/src/client.ts +++ b/src/client.ts @@ -373,7 +373,7 @@ export class OpenAI { * @param {string | null | undefined} [opts.webhookSecret=process.env['OPENAI_WEBHOOK_SECRET'] ?? null] * @param {string} [opts.baseURL=process.env['OPENAI_BASE_URL'] ?? https://api.openai.com/v1] - Override the default base URL for the API. * @param {number} [opts.timeout=10 minutes] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. - * @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls. + * @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options forwarded to the active `fetch` implementation. * @param {Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. * @param {number} [opts.maxRetries=2] - The maximum number of times the client will retry a request. * @param {HeadersLike} opts.defaultHeaders - Default headers to include with every request to the API. diff --git a/src/internal/shims.ts b/src/internal/shims.ts index 588ce43ab..b1a7a0207 100644 --- a/src/internal/shims.ts +++ b/src/internal/shims.ts @@ -10,8 +10,32 @@ import type { Fetch } from './builtin-types'; import type { ReadableStream } from './shim-types'; +type NodeTLSModule = { + getCACertificates?: ((type?: 'default' | 'extra') => string[]) | undefined; + setDefaultCACertificates?: ((certificates: string[]) => void) | undefined; +}; + +function getNodeTLSModule(): NodeTLSModule | undefined { + const process = (globalThis as any).process; + if (typeof process?.getBuiltinModule !== 'function') return undefined; + return process.getBuiltinModule('node:tls') as NodeTLSModule | undefined; +} + +function applyNodeExtraCACertificates(): void { + const tls = getNodeTLSModule(); + if (typeof tls?.getCACertificates !== 'function' || typeof tls?.setDefaultCACertificates !== 'function') { + return; + } + + const extraCertificates = tls.getCACertificates('extra'); + if (!extraCertificates.length) return; + + tls.setDefaultCACertificates([...tls.getCACertificates('default'), ...extraCertificates]); +} + export function getDefaultFetch(): Fetch { if (typeof fetch !== 'undefined') { + applyNodeExtraCACertificates(); return fetch as any; } diff --git a/tests/index.test.ts b/tests/index.test.ts index bafbbd4f6..ac68a120a 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -6,6 +6,7 @@ import util from 'node:util'; import OpenAI from 'openai'; import { APIUserAbortError } from 'openai'; const defaultFetch = fetch; +const defaultGetBuiltinModule = (process as typeof process & { getBuiltinModule?: unknown }).getBuiltinModule; describe('instantiate client', () => { const env = process.env; @@ -17,6 +18,12 @@ describe('instantiate client', () => { afterEach(() => { process.env = env; + const processWithBuiltins = process as typeof process & { getBuiltinModule?: unknown }; + if (defaultGetBuiltinModule === undefined) { + delete processWithBuiltins.getBuiltinModule; + } else { + processWithBuiltins.getBuiltinModule = defaultGetBuiltinModule; + } }); describe('defaultHeaders', () => { @@ -250,6 +257,71 @@ describe('instantiate client', () => { }); }); + test('merges NODE_EXTRA_CA_CERTS into the default Node CA store before using global fetch', async () => { + const getCACertificates = jest.fn((type?: 'default' | 'extra') => + type === 'extra' ? ['extra-ca'] : ['default-ca'], + ); + const setDefaultCACertificates = jest.fn(); + const processWithBuiltins = process as typeof process & { + getBuiltinModule?: (id: string) => unknown; + }; + + process.env['NODE_EXTRA_CA_CERTS'] = '/tmp/corporate-ca.pem'; + processWithBuiltins.getBuiltinModule = jest.fn((id: string) => + id === 'node:tls' ? { getCACertificates, setDefaultCACertificates } : undefined, + ); + + new OpenAI({ + baseURL: 'http://localhost:5000/', + apiKey: 'My API Key', + }); + + expect(getCACertificates).toHaveBeenCalledWith('extra'); + expect(getCACertificates).toHaveBeenCalledWith('default'); + expect(setDefaultCACertificates).toHaveBeenCalledWith(['default-ca', 'extra-ca']); + }); + + test('merges loaded extra CA certificates even after NODE_EXTRA_CA_CERTS is scrubbed', async () => { + const getCACertificates = jest.fn((type?: 'default' | 'extra') => + type === 'extra' ? ['extra-ca'] : ['default-ca'], + ); + const setDefaultCACertificates = jest.fn(); + const processWithBuiltins = process as typeof process & { + getBuiltinModule?: (id: string) => unknown; + }; + + delete process.env['NODE_EXTRA_CA_CERTS']; + processWithBuiltins.getBuiltinModule = jest.fn((id: string) => + id === 'node:tls' ? { getCACertificates, setDefaultCACertificates } : undefined, + ); + + new OpenAI({ + baseURL: 'http://localhost:5000/', + apiKey: 'My API Key', + }); + + expect(getCACertificates).toHaveBeenCalledWith('extra'); + expect(getCACertificates).toHaveBeenCalledWith('default'); + expect(setDefaultCACertificates).toHaveBeenCalledWith(['default-ca', 'extra-ca']); + }); + + test('skips NODE_EXTRA_CA_CERTS setup when Node does not expose the CA APIs', async () => { + const processWithBuiltins = process as typeof process & { + getBuiltinModule?: (id: string) => unknown; + }; + + process.env['NODE_EXTRA_CA_CERTS'] = '/tmp/corporate-ca.pem'; + processWithBuiltins.getBuiltinModule = jest.fn(() => ({})); + + expect( + () => + new OpenAI({ + baseURL: 'http://localhost:5000/', + apiKey: 'My API Key', + }), + ).not.toThrow(); + }); + test('custom signal', async () => { const client = new OpenAI({ baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', @@ -293,6 +365,36 @@ describe('instantiate client', () => { expect(capturedRequest?.method).toEqual('PATCH'); }); + test('passes merged fetchOptions through to custom fetch', async () => { + let capturedRequest: RequestInit | undefined; + const clientDispatcher = { scope: 'client' } as any; + const requestDispatcher = { scope: 'request' } as any; + + const client = new OpenAI({ + baseURL: 'http://localhost:5000/', + apiKey: 'My API Key', + fetchOptions: { cache: 'no-store', dispatcher: clientDispatcher } as any, + fetch: async (url: string | URL | Request, init: RequestInit = {}): Promise => { + capturedRequest = init; + return new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + + await client.get('/foo', { + fetchOptions: { dispatcher: requestDispatcher, integrity: 'request-integrity' } as any, + }); + + expect(capturedRequest).toMatchObject({ + method: 'GET', + cache: 'no-store', + integrity: 'request-integrity', + dispatcher: requestDispatcher, + }); + expect((capturedRequest as any)?.dispatcher).not.toBe(clientDispatcher); + }); + describe('baseUrl', () => { test('trailing slash', () => { const client = new OpenAI({ baseURL: 'http://localhost:5000/custom/path/', apiKey: 'My API Key' });