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
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions src/internal/shims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
102 changes: 102 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<Response> => {
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' });
Expand Down