Skip to content

Commit 51c8c02

Browse files
deyaaeldeenstainless-app[bot]
authored andcommitted
feat(client): support api key provider functions (#1587)
1 parent 7b51004 commit 51c8c02

File tree

6 files changed

+198
-99
lines changed

6 files changed

+198
-99
lines changed

src/azure.ts

Lines changed: 1 addition & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as Errors from './error';
33
import { FinalRequestOptions } from './internal/request-options';
44
import { isObj, readEnv } from './internal/utils';
55
import { ClientOptions, OpenAI } from './client';
6-
import { buildHeaders, NullableHeaders } from './internal/headers';
76

87
/** API Client for interfacing with the Azure OpenAI API. */
98
export interface AzureClientOptions extends ClientOptions {
@@ -37,7 +36,6 @@ export interface AzureClientOptions extends ClientOptions {
3736

3837
/** API Client for interfacing with the Azure OpenAI API. */
3938
export class AzureOpenAI extends OpenAI {
40-
private _azureADTokenProvider: (() => Promise<string>) | undefined;
4139
deploymentName: string | undefined;
4240
apiVersion: string = '';
4341

@@ -90,9 +88,6 @@ export class AzureOpenAI extends OpenAI {
9088
);
9189
}
9290

93-
// define a sentinel value to avoid any typing issues
94-
apiKey ??= API_KEY_SENTINEL;
95-
9691
opts.defaultQuery = { ...opts.defaultQuery, 'api-version': apiVersion };
9792

9893
if (!baseURL) {
@@ -114,13 +109,12 @@ export class AzureOpenAI extends OpenAI {
114109
}
115110

116111
super({
117-
apiKey,
112+
apiKey: azureADTokenProvider ?? apiKey,
118113
baseURL,
119114
...opts,
120115
...(dangerouslyAllowBrowser !== undefined ? { dangerouslyAllowBrowser } : {}),
121116
});
122117

123-
this._azureADTokenProvider = azureADTokenProvider;
124118
this.apiVersion = apiVersion;
125119
this.deploymentName = deployment;
126120
}
@@ -140,47 +134,6 @@ export class AzureOpenAI extends OpenAI {
140134
}
141135
return super.buildRequest(options, props);
142136
}
143-
144-
async _getAzureADToken(): Promise<string | undefined> {
145-
if (typeof this._azureADTokenProvider === 'function') {
146-
const token = await this._azureADTokenProvider();
147-
if (!token || typeof token !== 'string') {
148-
throw new Errors.OpenAIError(
149-
`Expected 'azureADTokenProvider' argument to return a string but it returned ${token}`,
150-
);
151-
}
152-
return token;
153-
}
154-
return undefined;
155-
}
156-
157-
protected override async authHeaders(opts: FinalRequestOptions): Promise<NullableHeaders | undefined> {
158-
return;
159-
}
160-
161-
protected override async prepareOptions(opts: FinalRequestOptions): Promise<void> {
162-
opts.headers = buildHeaders([opts.headers]);
163-
164-
/**
165-
* The user should provide a bearer token provider if they want
166-
* to use Azure AD authentication. The user shouldn't set the
167-
* Authorization header manually because the header is overwritten
168-
* with the Azure AD token if a bearer token provider is provided.
169-
*/
170-
if (opts.headers.values.get('Authorization') || opts.headers.values.get('api-key')) {
171-
return super.prepareOptions(opts);
172-
}
173-
174-
const token = await this._getAzureADToken();
175-
if (token) {
176-
opts.headers.values.set('Authorization', `Bearer ${token}`);
177-
} else if (this.apiKey !== API_KEY_SENTINEL) {
178-
opts.headers.values.set('api-key', this.apiKey);
179-
} else {
180-
throw new Errors.OpenAIError('Unable to handle auth');
181-
}
182-
return super.prepareOptions(opts);
183-
}
184137
}
185138

186139
const _deployments_endpoints = new Set([
@@ -194,5 +147,3 @@ const _deployments_endpoints = new Set([
194147
'/batches',
195148
'/images/edits',
196149
]);
197-
198-
const API_KEY_SENTINEL = '<Missing Key>';

src/beta/realtime/websocket.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,17 @@ export class OpenAIRealtimeWebSocket extends OpenAIRealtimeEmitter {
3131
* @internal
3232
*/
3333
onURL?: (url: URL) => void;
34+
/** Indicates the token was resolved by the factory just before connecting. @internal */
35+
__resolvedApiKey?: boolean;
3436
},
3537
client?: Pick<OpenAI, 'apiKey' | 'baseURL'>,
3638
) {
3739
super();
38-
40+
const hasProvider = typeof (client as any)?._options?.apiKey === 'function';
3941
const dangerouslyAllowBrowser =
4042
props.dangerouslyAllowBrowser ??
4143
(client as any)?._options?.dangerouslyAllowBrowser ??
42-
(client?.apiKey.startsWith('ek_') ? true : null);
43-
44+
(client?.apiKey?.startsWith('ek_') ? true : null);
4445
if (!dangerouslyAllowBrowser && isRunningInBrowser()) {
4546
throw new OpenAIError(
4647
"It looks like you're running in a browser-like environment.\n\nThis is disabled by default, as it risks exposing your secret API credentials to attackers.\n\nYou can avoid this error by creating an ephemeral session token:\nhttps://platform.openai.com/docs/api-reference/realtime-sessions\n",
@@ -49,6 +50,16 @@ export class OpenAIRealtimeWebSocket extends OpenAIRealtimeEmitter {
4950

5051
client ??= new OpenAI({ dangerouslyAllowBrowser });
5152

53+
if (hasProvider && !props?.__resolvedApiKey) {
54+
throw new Error(
55+
[
56+
'Cannot open Realtime WebSocket with a function-based apiKey.',
57+
'Use the .create() method so that the key is resolved before connecting:',
58+
'await OpenAIRealtimeWebSocket.create(client, { model })',
59+
].join('\n'),
60+
);
61+
}
62+
5263
this.url = buildRealtimeURL(client, props.model);
5364
props.onURL?.(this.url);
5465

@@ -94,20 +105,23 @@ export class OpenAIRealtimeWebSocket extends OpenAIRealtimeEmitter {
94105
}
95106
}
96107

108+
static async create(
109+
client: Pick<OpenAI, 'apiKey' | 'baseURL' | '_callApiKey'>,
110+
props: { model: string; dangerouslyAllowBrowser?: boolean },
111+
): Promise<OpenAIRealtimeWebSocket> {
112+
return new OpenAIRealtimeWebSocket({ ...props, __resolvedApiKey: await client._callApiKey() }, client);
113+
}
114+
97115
static async azure(
98-
client: Pick<AzureOpenAI, '_getAzureADToken' | 'apiVersion' | 'apiKey' | 'baseURL' | 'deploymentName'>,
116+
client: Pick<AzureOpenAI, '_callApiKey' | 'apiVersion' | 'apiKey' | 'baseURL' | 'deploymentName'>,
99117
options: { deploymentName?: string; dangerouslyAllowBrowser?: boolean } = {},
100118
): Promise<OpenAIRealtimeWebSocket> {
101-
const token = await client._getAzureADToken();
119+
const isApiKeyProvider = await client._callApiKey();
102120
function onURL(url: URL) {
103-
if (client.apiKey !== '<Missing Key>') {
104-
url.searchParams.set('api-key', client.apiKey);
121+
if (isApiKeyProvider) {
122+
url.searchParams.set('Authorization', `Bearer ${client.apiKey}`);
105123
} else {
106-
if (token) {
107-
url.searchParams.set('Authorization', `Bearer ${token}`);
108-
} else {
109-
throw new Error('AzureOpenAI is not instantiated correctly. No API key or token provided.');
110-
}
124+
url.searchParams.set('api-key', client.apiKey);
111125
}
112126
}
113127
const deploymentName = options.deploymentName ?? client.deploymentName;
@@ -120,6 +134,7 @@ export class OpenAIRealtimeWebSocket extends OpenAIRealtimeEmitter {
120134
model: deploymentName,
121135
onURL,
122136
...(dangerouslyAllowBrowser ? { dangerouslyAllowBrowser } : {}),
137+
__resolvedApiKey: isApiKeyProvider,
123138
},
124139
client,
125140
);

src/beta/realtime/ws.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,31 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter {
88
socket: WS.WebSocket;
99

1010
constructor(
11-
props: { model: string; options?: WS.ClientOptions | undefined },
11+
props: {
12+
model: string;
13+
options?: WS.ClientOptions | undefined;
14+
/** @internal */ __resolvedApiKey?: boolean;
15+
},
1216
client?: Pick<OpenAI, 'apiKey' | 'baseURL'>,
1317
) {
1418
super();
1519
client ??= new OpenAI();
16-
20+
const hasProvider = typeof (client as any)?._options?.apiKey === 'function';
21+
if (hasProvider && !props.__resolvedApiKey) {
22+
throw new Error(
23+
[
24+
'Cannot open Realtime WebSocket with a function-based apiKey.',
25+
'Use the .create() method so that the key is resolved before connecting:',
26+
'await OpenAIRealtimeWS.create(client, { model })',
27+
].join('\n'),
28+
);
29+
}
1730
this.url = buildRealtimeURL(client, props.model);
1831
this.socket = new WS.WebSocket(this.url, {
1932
...props.options,
2033
headers: {
2134
...props.options?.headers,
22-
...(isAzure(client) ? {} : { Authorization: `Bearer ${client.apiKey}` }),
35+
...(isAzure(client) && !props.__resolvedApiKey ? {} : { Authorization: `Bearer ${client.apiKey}` }),
2336
'OpenAI-Beta': 'realtime=v1',
2437
},
2538
});
@@ -51,16 +64,34 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter {
5164
});
5265
}
5366

67+
static async create(
68+
client: Pick<OpenAI, 'apiKey' | 'baseURL' | '_callApiKey'>,
69+
props: { model: string; options?: WS.ClientOptions | undefined },
70+
): Promise<OpenAIRealtimeWS> {
71+
return new OpenAIRealtimeWS({ ...props, __resolvedApiKey: await client._callApiKey() }, client);
72+
}
73+
5474
static async azure(
55-
client: Pick<AzureOpenAI, '_getAzureADToken' | 'apiVersion' | 'apiKey' | 'baseURL' | 'deploymentName'>,
56-
options: { deploymentName?: string; options?: WS.ClientOptions | undefined } = {},
75+
client: Pick<AzureOpenAI, '_callApiKey' | 'apiVersion' | 'apiKey' | 'baseURL' | 'deploymentName'>,
76+
props: { deploymentName?: string; options?: WS.ClientOptions | undefined } = {},
5777
): Promise<OpenAIRealtimeWS> {
58-
const deploymentName = options.deploymentName ?? client.deploymentName;
78+
const isApiKeyProvider = await client._callApiKey();
79+
const deploymentName = props.deploymentName ?? client.deploymentName;
5980
if (!deploymentName) {
6081
throw new Error('No deployment name provided');
6182
}
6283
return new OpenAIRealtimeWS(
63-
{ model: deploymentName, options: { headers: await getAzureHeaders(client) } },
84+
{
85+
model: deploymentName,
86+
options: {
87+
...props.options,
88+
headers: {
89+
...props.options?.headers,
90+
...(isApiKeyProvider ? {} : { 'api-key': client.apiKey }),
91+
},
92+
},
93+
__resolvedApiKey: isApiKeyProvider,
94+
},
6495
client,
6596
);
6697
}
@@ -81,16 +112,3 @@ export class OpenAIRealtimeWS extends OpenAIRealtimeEmitter {
81112
}
82113
}
83114
}
84-
85-
async function getAzureHeaders(client: Pick<AzureOpenAI, '_getAzureADToken' | 'apiKey'>) {
86-
if (client.apiKey !== '<Missing Key>') {
87-
return { 'api-key': client.apiKey };
88-
} else {
89-
const token = await client._getAzureADToken();
90-
if (token) {
91-
return { Authorization: `Bearer ${token}` };
92-
} else {
93-
throw new Error('AzureOpenAI is not instantiated correctly. No API key or token provided.');
94-
}
95-
}
96-
}

src/client.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,21 @@ import {
207207
} from './internal/utils/log';
208208
import { isEmptyObj } from './internal/utils/values';
209209

210+
export type ApiKeySetter = () => Promise<string>;
211+
210212
export interface ClientOptions {
211213
/**
212-
* Defaults to process.env['OPENAI_API_KEY'].
214+
* API key used for authentication.
215+
*
216+
* - Accepts either a static string or an async function that resolves to a string.
217+
* - Defaults to process.env['OPENAI_API_KEY'].
218+
* - When a function is provided, it is invoked before each request so you can rotate
219+
* or refresh credentials at runtime.
220+
* - The function must return a non-empty string; otherwise an OpenAIError is thrown.
221+
* - If the function throws, the error is wrapped in an OpenAIError with the original
222+
* error available as `cause`.
213223
*/
214-
apiKey?: string | undefined;
215-
224+
apiKey?: string | ApiKeySetter | undefined;
216225
/**
217226
* Defaults to process.env['OPENAI_ORG_ID'].
218227
*/
@@ -350,7 +359,7 @@ export class OpenAI {
350359
}: ClientOptions = {}) {
351360
if (apiKey === undefined) {
352361
throw new Errors.OpenAIError(
353-
"The OPENAI_API_KEY environment variable is missing or empty; either provide it, or instantiate the OpenAI client with an apiKey option, like new OpenAI({ apiKey: 'My API Key' }).",
362+
'Missing credentials. Please pass an `apiKey`, or set the `OPENAI_API_KEY` environment variable.',
354363
);
355364
}
356365

@@ -386,7 +395,7 @@ export class OpenAI {
386395

387396
this._options = options;
388397

389-
this.apiKey = apiKey;
398+
this.apiKey = typeof apiKey === 'string' ? apiKey : 'Missing Key';
390399
this.organization = organization;
391400
this.project = project;
392401
this.webhookSecret = webhookSecret;
@@ -454,6 +463,31 @@ export class OpenAI {
454463
return Errors.APIError.generate(status, error, message, headers);
455464
}
456465

466+
async _callApiKey(): Promise<boolean> {
467+
const apiKey = this._options.apiKey;
468+
if (typeof apiKey !== 'function') return false;
469+
470+
let token: unknown;
471+
try {
472+
token = await apiKey();
473+
} catch (err: any) {
474+
if (err instanceof Errors.OpenAIError) throw err;
475+
throw new Errors.OpenAIError(
476+
`Failed to get token from 'apiKey' function: ${err.message}`,
477+
// @ts-ignore
478+
{ cause: err },
479+
);
480+
}
481+
482+
if (typeof token !== 'string' || !token) {
483+
throw new Errors.OpenAIError(
484+
`Expected 'apiKey' function argument to return a string but it returned ${token}`,
485+
);
486+
}
487+
this.apiKey = token;
488+
return true;
489+
}
490+
457491
buildURL(
458492
path: string,
459493
query: Record<string, unknown> | null | undefined,
@@ -480,7 +514,9 @@ export class OpenAI {
480514
/**
481515
* Used as a callback for mutating the given `FinalRequestOptions` object.
482516
*/
483-
protected async prepareOptions(options: FinalRequestOptions): Promise<void> {}
517+
protected async prepareOptions(options: FinalRequestOptions): Promise<void> {
518+
await this._callApiKey();
519+
}
484520

485521
/**
486522
* Used as a callback for mutating the given `RequestInit` object.

0 commit comments

Comments
 (0)