@webex/internal-plugin-llm 3.11.0 → 3.12.0-llmrefactor.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ /* eslint-disable require-jsdoc */
2
+ import {WebexPlugin} from '@webex/webex-core';
3
+ import LLMChannel, {config} from './llm';
4
+ import {DATA_CHANNEL_WITH_JWT_TOKEN} from './constants';
5
+
6
+ /**
7
+ * LLMPlugin — registered as `webex.internal.llm`.
8
+ *
9
+ * Factory for creating LLMChannel instances. Each Meeting creates and owns
10
+ * its own LLMChannel(s), allowing multiple independent connections without
11
+ * the need for session IDs or ownership tracking.
12
+ *
13
+ * This is a breaking API change from the previous design where the plugin
14
+ * maintained a Map of sessions and exposed session-keyed methods.
15
+ *
16
+ * Old usage (no longer supported):
17
+ * webex.internal.llm.isConnected()
18
+ * webex.internal.llm.registerAndConnect(url, dcUrl, token, sessionId)
19
+ *
20
+ * New usage:
21
+ * const llm = webex.internal.llm.createChannel();
22
+ * await llm.registerAndConnect(url, dcUrl, token);
23
+ * llm.isConnected();
24
+ * // When done:
25
+ * await llm.disconnect();
26
+ */
27
+ export class LLMPlugin extends (WebexPlugin as any) {
28
+ namespace = 'llm';
29
+
30
+ /**
31
+ * Registry of active LLM channels for interceptor lookup.
32
+ * Channels are registered when created and unregistered on disconnect.
33
+ */
34
+ private channels = new Set<LLMChannel>();
35
+
36
+ /**
37
+ * Creates a new LLMChannel instance. The caller owns the channel and is
38
+ * responsible for connecting, disconnecting, and cleaning it up.
39
+ *
40
+ * @example
41
+ * const llm = webex.internal.llm.createChannel();
42
+ * llm.setRefreshHandler(() => meeting.refreshDataChannelToken());
43
+ * await llm.registerAndConnect(locusUrl, datachannelUrl, token);
44
+ *
45
+ * // Subscribe to events directly on the channel
46
+ * llm.on('event:relay.event', handler);
47
+ * llm.on('online', onlineHandler);
48
+ *
49
+ * // When done
50
+ * await llm.disconnect();
51
+ *
52
+ * @returns {LLMChannel} A new LLM channel instance
53
+ */
54
+ public createChannel(): LLMChannel {
55
+ // @ts-ignore — WebexPlugin children require {parent: this.webex}
56
+ const channel = new LLMChannel({parent: this.webex});
57
+
58
+ this.channels.add(channel);
59
+ channel.on('disconnected', () => this.channels.delete(channel));
60
+
61
+ return channel;
62
+ }
63
+
64
+ /**
65
+ * Returns true if the data channel token feature flag is enabled.
66
+ * This is a global check, not per-connection.
67
+ * @returns {Promise<boolean>}
68
+ */
69
+ public isDataChannelTokenEnabled(): Promise<boolean> {
70
+ // @ts-ignore
71
+ return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN);
72
+ }
73
+
74
+ /**
75
+ * Find a channel by its datachannel URL. Used by the interceptor to
76
+ * route token refresh requests to the correct channel.
77
+ * @param {string} url - The request URL to match
78
+ * @returns {LLMChannel | undefined}
79
+ */
80
+ public getChannelByDatachannelUrl(url: string): LLMChannel | undefined {
81
+ for (const channel of this.channels) {
82
+ const datachannelUrl = channel.getDatachannelUrl();
83
+
84
+ if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(url, datachannelUrl)) {
85
+ return channel;
86
+ }
87
+ }
88
+
89
+ return undefined;
90
+ }
91
+
92
+ /**
93
+ * Get all active channels. Useful for diagnostics/debugging.
94
+ * @returns {Set<LLMChannel>}
95
+ */
96
+ public getAllChannels(): Set<LLMChannel> {
97
+ return new Set(this.channels);
98
+ }
99
+
100
+ /**
101
+ * Disconnect all active channels. Useful for cleanup on logout.
102
+ * @param {object} [options] - Disconnect options
103
+ * @param {number} [options.code] - WebSocket close code
104
+ * @param {string} [options.reason] - WebSocket close reason
105
+ * @returns {Promise<void>}
106
+ */
107
+ public async disconnectAllChannels(options?: {code: number; reason: string}): Promise<void> {
108
+ const promises = Array.from(this.channels).map((channel) => channel.disconnect(options));
109
+
110
+ await Promise.all(promises);
111
+ }
112
+ }
113
+
114
+ export {config};
115
+ export default LLMPlugin;
package/src/llm.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  /* eslint-disable consistent-return */
2
-
3
2
  import Mercury from '@webex/internal-plugin-mercury';
4
3
 
5
- import {LLM} from './constants';
6
- // eslint-disable-next-line no-unused-vars
7
- import {ILLMChannel} from './llm.types';
4
+ import {
5
+ LLM,
6
+ DATA_CHANNEL_WITH_JWT_TOKEN,
7
+ AWARE_DATA_CHANNEL,
8
+ SUBSCRIPTION_AWARE_SUBCHANNELS_PARAM,
9
+ } from './constants';
10
+ import {ILLMChannel, DataChannelTokenType, RegisterAndConnectTiming} from './llm.types';
8
11
 
9
12
  export const config = {
10
13
  llm: {
@@ -38,59 +41,128 @@ export const config = {
38
41
  };
39
42
 
40
43
  /**
41
- * LLMChannel to provide socket connections
44
+ * LLMChannel — a single WebSocket connection to the LLM data channel.
45
+ *
46
+ * Created via `webex.internal.llm.createChannel()`. The caller owns the
47
+ * channel and is responsible for its lifecycle (connect, disconnect, cleanup).
48
+ * Multiple LLMChannels can exist simultaneously for different meetings.
42
49
  */
43
50
  export default class LLMChannel extends (Mercury as any) implements ILLMChannel {
44
51
  namespace = LLM;
45
52
 
46
- /**
47
- * If the LLM plugin has been registered and listening
48
- * @instance
49
- * @type {Boolean}
50
- * @public
51
- */
52
-
53
53
  private webSocketUrl?: string;
54
-
55
54
  private binding?: string;
56
-
57
55
  private locusUrl?: string;
58
-
59
56
  private datachannelUrl?: string;
57
+ private datachannelToken?: string;
58
+ private refreshHandler?: () => Promise<{
59
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
60
+ }>;
61
+
62
+ /** In-flight connection promise for deduplication. */
63
+ private connectingPromise?: Promise<RegisterAndConnectTiming | void>;
64
+
65
+ /**
66
+ * Check if a connection is currently in progress.
67
+ * @returns {boolean} True if connecting
68
+ */
69
+ public isConnecting(): boolean {
70
+ return !!this.connectingPromise;
71
+ }
60
72
 
61
73
  /**
62
74
  * Register to the websocket
63
75
  * @param {string} llmSocketUrl
76
+ * @param {string} datachannelToken
64
77
  * @returns {Promise<void>}
65
78
  */
66
- private register = (llmSocketUrl: string): Promise<void> =>
67
- this.request({
79
+ private register = async (llmSocketUrl: string, datachannelToken?: string): Promise<void> => {
80
+ const isDataChannelTokenEnabled = await this.isDataChannelTokenEnabled();
81
+
82
+ return this.request({
68
83
  method: 'POST',
69
84
  url: llmSocketUrl,
70
85
  body: {deviceUrl: this.webex.internal.device.url},
86
+ headers:
87
+ isDataChannelTokenEnabled && datachannelToken
88
+ ? {'Data-Channel-Auth-Token': datachannelToken}
89
+ : {},
71
90
  })
72
91
  .then((res: {body: {webSocketUrl: string; binding: string}}) => {
73
92
  this.webSocketUrl = res.body.webSocketUrl;
74
93
  this.binding = res.body.binding;
75
94
  })
76
95
  .catch((error: any) => {
77
- this.logger.error(`Error connecting to websocket: ${error}`);
96
+ this.logger.error(`Error connecting to websocket for : ${error}`);
78
97
  throw error;
79
98
  });
99
+ };
80
100
 
81
101
  /**
82
- * Register and connect to the websocket
102
+ * Register and connect to the websocket.
103
+ * Handles deduplication: returns existing promise if connection in progress,
104
+ * or resolves immediately if already connected to the same URLs.
83
105
  * @param {string} locusUrl
84
106
  * @param {string} datachannelUrl
85
- * @returns {Promise<void>}
107
+ * @param {string} datachannelToken
108
+ * @returns {Promise<RegisterAndConnectTiming | void>}
86
109
  */
87
- public registerAndConnect = (locusUrl: string, datachannelUrl: string): Promise<void> =>
88
- this.register(datachannelUrl).then(() => {
89
- if (!locusUrl || !datachannelUrl) return undefined;
90
- this.locusUrl = locusUrl;
91
- this.datachannelUrl = datachannelUrl;
92
- this.connect(this.webSocketUrl);
93
- });
110
+ public registerAndConnect = (
111
+ locusUrl: string,
112
+ datachannelUrl: string,
113
+ datachannelToken?: string
114
+ ): Promise<RegisterAndConnectTiming | void> => {
115
+ // Deduplicate concurrent calls while a connection is in-flight
116
+ if (this.connectingPromise) {
117
+ return this.connectingPromise;
118
+ }
119
+
120
+ // If already connected to the exact same datachannel URL, avoid triggering
121
+ // a reconnect that would cause the server to replace with 4000 Replaced
122
+ if (
123
+ this.isConnected() &&
124
+ this.datachannelUrl === datachannelUrl &&
125
+ this.locusUrl === locusUrl
126
+ ) {
127
+ return Promise.resolve();
128
+ }
129
+
130
+ this.locusUrl = locusUrl;
131
+ this.datachannelUrl = datachannelUrl;
132
+ const registerStart = performance.now();
133
+
134
+ this.connectingPromise = this.register(datachannelUrl, datachannelToken)
135
+ .then(async () => {
136
+ const clientLLMDatachannelResponseTime = Math.round(performance.now() - registerStart);
137
+ const isDataChannelTokenEnabled = await this.isDataChannelTokenEnabled();
138
+
139
+ const connectUrl =
140
+ isDataChannelTokenEnabled && this.webSocketUrl
141
+ ? LLMChannel.buildUrlWithAwareSubchannels(this.webSocketUrl, AWARE_DATA_CHANNEL)
142
+ : this.webSocketUrl;
143
+
144
+ const connectStart = performance.now();
145
+
146
+ try {
147
+ await this.connect(connectUrl);
148
+ } catch (error) {
149
+ // register() succeeded; only connect() failed. Attach the measured datachannel time so
150
+ // callers don't misreport a websocket failure as a registration that never completed.
151
+ // @ts-ignore
152
+ error.timing = {clientLLMDatachannelResponseTime};
153
+ throw error;
154
+ }
155
+
156
+ const clientLLMWebSocketConnectTime = Math.round(performance.now() - connectStart);
157
+
158
+ return {clientLLMDatachannelResponseTime, clientLLMWebSocketConnectTime};
159
+ })
160
+ .finally(() => {
161
+ this.connectingPromise = undefined;
162
+ });
163
+
164
+ return this.connectingPromise;
165
+ };
94
166
 
95
167
  /**
96
168
  * Tells if LLM socket is connected
@@ -100,32 +172,147 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel
100
172
 
101
173
  /**
102
174
  * Tells if LLM socket is binding
103
- * @returns {string} binding
175
+ * @returns {string | undefined} binding
104
176
  */
105
- public getBinding = (): string => this.binding;
177
+ public getBinding = (): string | undefined => this.binding;
106
178
 
107
179
  /**
108
180
  * Get Locus URL for the connection
109
- * @returns {string} locus Url
181
+ * @returns {string | undefined} locus Url
110
182
  */
111
- public getLocusUrl = (): string => this.locusUrl;
183
+ public getLocusUrl = (): string | undefined => this.locusUrl;
112
184
 
113
185
  /**
114
186
  * Get data channel URL for the connection
115
- * @returns {string} data channel Url
187
+ * @returns {string | undefined} data channel Url
116
188
  */
117
- public getDatachannelUrl = (): string => this.datachannelUrl;
189
+ public getDatachannelUrl = (): string | undefined => this.datachannelUrl;
118
190
 
119
191
  /**
120
- * Disconnects websocket connection
121
- * @param {{code: number, reason: string}} options - The disconnect option object with code and reason
192
+ * Get data channel token for this connection.
193
+ * @returns {string | undefined}
194
+ */
195
+ public getDatachannelToken = (): string | undefined => this.datachannelToken;
196
+
197
+ /**
198
+ * Store the data channel token for this connection.
199
+ * @param {string} token
200
+ * @returns {void}
201
+ */
202
+ public setDatachannelToken = (token: string): void => {
203
+ this.datachannelToken = token;
204
+ };
205
+
206
+ /**
207
+ * Clear the data channel token for this connection.
208
+ * @returns {void}
209
+ */
210
+ public clearDatachannelToken = (): void => {
211
+ this.datachannelToken = undefined;
212
+ };
213
+
214
+ /**
215
+ * Set the handler used to refresh the DataChannel token.
216
+ * @param {function} handler
217
+ * @returns {void}
218
+ */
219
+ public setRefreshHandler = (
220
+ handler: () => Promise<{
221
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
222
+ }>
223
+ ): void => {
224
+ this.refreshHandler = handler;
225
+ };
226
+
227
+ /**
228
+ * Refresh the data channel token using the injected handler.
229
+ * @returns {Promise<object | null>}
230
+ */
231
+ public async refreshDataChannelToken() {
232
+ if (!this.refreshHandler) {
233
+ this.logger.warn(
234
+ 'llm#refreshDataChannelToken --> LLM refreshHandler is not set, skipping token refresh'
235
+ );
236
+
237
+ return null;
238
+ }
239
+
240
+ try {
241
+ return await this.refreshHandler();
242
+ } catch (error: any) {
243
+ this.logger.warn(
244
+ `llm#refreshDataChannelToken --> DataChannel token refresh failed: ${
245
+ error?.message || error
246
+ }`
247
+ );
248
+
249
+ return null;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Disconnects the WebSocket and clears all connection state.
255
+ * Overrides Mercury's disconnect to also clear LLM-specific state.
256
+ * @param {object} [options]
122
257
  * @returns {Promise<void>}
123
258
  */
124
- public disconnectLLM = (options: object): Promise<void> =>
125
- this.disconnect(options).then(() => {
126
- this.locusUrl = undefined;
127
- this.datachannelUrl = undefined;
128
- this.binding = undefined;
129
- this.webSocketUrl = undefined;
130
- });
259
+ public async disconnect(options?: {code: number; reason: string}): Promise<void> {
260
+ await super.disconnect(options);
261
+ this.webSocketUrl = undefined;
262
+ this.binding = undefined;
263
+ this.locusUrl = undefined;
264
+ this.datachannelUrl = undefined;
265
+ this.datachannelToken = undefined;
266
+ this.refreshHandler = undefined;
267
+ this.emit('disconnected');
268
+ }
269
+
270
+ /**
271
+ * Matches a request URL to a stored datachannel registration URL.
272
+ * Host can differ (e.g. rewritten by hostmap interceptor), so we first
273
+ * try full URL prefix and then fall back to pathname prefix.
274
+ * @param {string} requestUrl
275
+ * @param {string} registrationUrl
276
+ * @returns {boolean}
277
+ */
278
+ public static matchesDatachannelRequestUrl(requestUrl: string, registrationUrl: string): boolean {
279
+ if (!requestUrl || !registrationUrl) {
280
+ return false;
281
+ }
282
+
283
+ if (requestUrl.startsWith(registrationUrl)) {
284
+ return true;
285
+ }
286
+
287
+ try {
288
+ const request = new URL(requestUrl);
289
+ const registration = new URL(registrationUrl);
290
+
291
+ return request.pathname.startsWith(registration.pathname);
292
+ } catch (error) {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Returns true if the data channel token feature flag is enabled.
299
+ * @returns {Promise<boolean>}
300
+ */
301
+ public isDataChannelTokenEnabled(): Promise<boolean> {
302
+ // @ts-ignore
303
+ return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN);
304
+ }
305
+
306
+ /**
307
+ * Builds a WebSocket URL with the `subscriptionAwareSubchannels` query parameter.
308
+ * @param {string} baseUrl
309
+ * @param {string[]} subchannels
310
+ * @returns {string}
311
+ */
312
+ public static buildUrlWithAwareSubchannels = (baseUrl: string, subchannels: string[]) => {
313
+ const urlObj = new URL(baseUrl);
314
+ urlObj.searchParams.set(SUBSCRIPTION_AWARE_SUBCHANNELS_PARAM, subchannels.join(','));
315
+
316
+ return urlObj.toString();
317
+ };
131
318
  }
package/src/llm.types.ts CHANGED
@@ -1,9 +1,96 @@
1
+ export enum DataChannelTokenType {
2
+ Default = 'llm-default-session',
3
+ PracticeSession = 'llm-practice-session',
4
+ }
5
+
6
+ type DataChannelTokenKey = DataChannelTokenType | string;
7
+
8
+ /**
9
+ * Latencies (in milliseconds) captured during register + websocket connect.
10
+ */
11
+ type RegisterAndConnectTiming = {
12
+ clientLLMDatachannelResponseTime?: number;
13
+ clientLLMWebSocketConnectTime?: number;
14
+ };
15
+
16
+ /**
17
+ * ILLMChannel — interface for a single LLM WebSocket connection.
18
+ * Created via `webex.internal.llm.createChannel()`.
19
+ */
1
20
  interface ILLMChannel {
2
- registerAndConnect: (locusUrl: string, datachannelUrl: string) => Promise<void>;
21
+ /** Register with the server and connect the WebSocket. */
22
+ registerAndConnect: (
23
+ locusUrl: string,
24
+ datachannelUrl: string,
25
+ datachannelToken?: string
26
+ ) => Promise<void>;
27
+
28
+ /** Returns true if the WebSocket is connected. */
3
29
  isConnected: () => boolean;
4
- getBinding: () => string;
5
- getLocusUrl: () => string;
6
- disconnectLLM: (options: {code: number; reason: string}) => Promise<void>;
30
+
31
+ /** Returns true if a connection is currently in progress. */
32
+ isConnecting: () => boolean;
33
+
34
+ /** Get the underlying WebSocket. */
35
+ getSocket: () => any;
36
+
37
+ /** Get the binding ID for this connection. */
38
+ getBinding: () => string | undefined;
39
+
40
+ /** Get the Locus URL associated with this connection. */
41
+ getLocusUrl: () => string | undefined;
42
+
43
+ /** Get the datachannel URL for this connection. */
44
+ getDatachannelUrl: () => string | undefined;
45
+
46
+ /** Get the stored datachannel token. */
47
+ getDatachannelToken: () => string | undefined;
48
+
49
+ /** Store a datachannel token for this connection. */
50
+ setDatachannelToken: (datachannelToken: string) => void;
51
+
52
+ /** Clear the stored datachannel token. */
53
+ clearDatachannelToken: () => void;
54
+
55
+ /** Set the handler used to refresh the datachannel token. */
56
+ setRefreshHandler: (
57
+ handler: () => Promise<{
58
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
59
+ }>
60
+ ) => void;
61
+
62
+ /** Refresh the datachannel token using the injected handler. */
63
+ refreshDataChannelToken: () => Promise<{
64
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
65
+ } | null>;
66
+
67
+ /** Disconnect the WebSocket and clean up state. */
68
+ disconnect: (options?: {code: number; reason: string}) => Promise<void>;
69
+
70
+ /** Check if the datachannel token feature flag is enabled. */
71
+ isDataChannelTokenEnabled: () => Promise<boolean>;
72
+ }
73
+
74
+ /**
75
+ * ILLMPlugin — interface for the LLM plugin factory.
76
+ * Accessed via `webex.internal.llm`.
77
+ */
78
+ interface ILLMPlugin {
79
+ /** Create a new LLM channel instance. */
80
+ createChannel: () => ILLMChannel;
81
+
82
+ /** Check if the datachannel token feature flag is enabled globally. */
83
+ isDataChannelTokenEnabled: () => Promise<boolean>;
84
+
85
+ /** Find a channel by matching a request URL to its datachannel URL. */
86
+ getChannelByDatachannelUrl: (url: string) => ILLMChannel | undefined;
87
+
88
+ /** Get all active channels. */
89
+ getAllChannels: () => Set<ILLMChannel>;
90
+
91
+ /** Disconnect all active channels. */
92
+ disconnectAllChannels: (options?: {code: number; reason: string}) => Promise<void>;
7
93
  }
94
+
8
95
  // eslint-disable-next-line import/prefer-default-export
9
- export type {ILLMChannel};
96
+ export type {ILLMChannel, ILLMPlugin, DataChannelTokenKey, RegisterAndConnectTiming};