Skip to content
Draft
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
3 changes: 2 additions & 1 deletion packages/@webex/internal-plugin-llm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import {DataChannelTokenType} from './llm.types';
WebexCore.registerInternalPlugin('llm', LLMPlugin, {
config,
onBeforeLogout() {
return this.disconnectAllLLM();
return this.disconnectAll();
},
});

export {DataChannelTokenType};
export {LLM_DEFAULT_SESSION, LLM_PRACTICE_SESSION} from './constants';
export {default} from './llm';
export {default as LLMPlugin} from './llm-plugin';
export type {ILLMChannel, ILLMPlugin} from './llm.types';
347 changes: 90 additions & 257 deletions packages/@webex/internal-plugin-llm/src/llm-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,255 +1,88 @@
/* eslint-disable require-jsdoc */
import {WebexPlugin} from '@webex/webex-core';
import LLMChannel, {config} from './llm';
import {DATA_CHANNEL_WITH_JWT_TOKEN, LLM_DEFAULT_SESSION} from './constants';
import {DataChannelTokenType} from './llm.types';
import {DATA_CHANNEL_WITH_JWT_TOKEN} from './constants';

/**
* LLMPlugin — registered as `webex.internal.llm`.
*
* Maintains a Map<sessionId, LLMChannel> so multiple simultaneous LLM
* connections (e.g. default session + practice session) can coexist.
* All existing callers continue to work unchanged via the session-keyed API.
* Factory for creating LLMChannel instances. Each Meeting creates and owns
* its own LLMChannel(s), allowing multiple independent connections without
* the need for session IDs or ownership tracking.
*
* sessionId values are the LLM_DEFAULT_SESSION / LLM_PRACTICE_SESSION constants,
* which match the DataChannelTokenType enum values so token keys and session keys
* are the same namespace.
* This is a breaking API change from the previous design where the plugin
* maintained a Map of sessions and exposed session-keyed methods.
*
* Old usage (no longer supported):
* webex.internal.llm.isConnected()
* webex.internal.llm.registerAndConnect(url, dcUrl, token, sessionId)
*
* New usage:
* const llm = webex.internal.llm.createConnection();
* await llm.registerAndConnect(url, dcUrl, token);
* llm.isConnected();
* // When done:
* await llm.disconnect();
*/
export class LLMPlugin extends (WebexPlugin as any) {
namespace = 'llm';

private sessions = new Map<string, LLMChannel>();

private getOrCreateSession(sessionId: string): LLMChannel {
let channel = this.sessions.get(sessionId);

if (!channel) {
// @ts-ignore — WebexPlugin children require {parent: this.webex}
channel = new LLMChannel({parent: this.webex});
// Forward all events emitted by the channel up through the plugin so that
// callers doing llm.on('event:relay.event', ...) or llm.on('online', ...)
// receive events from whichever session channel emits them.
// Non-default sessions emit events with :<sessionId> suffix so that
// practice-session consumers can subscribe to session-specific names
// like `event:relay.event:llm-practice-session`.
channel.on('all', (eventName: string, ...args: any[]) => {
if (sessionId === LLM_DEFAULT_SESSION) {
this.trigger(eventName, ...args);
} else {
this.trigger(`${eventName}:${sessionId}`, ...args);
}
});
this.sessions.set(sessionId, channel);
}

return channel;
}

private getSession(sessionId: string): LLMChannel | undefined {
return this.sessions.get(sessionId);
}

// Before webex.internal.llm was LLChannel instance which extended Mercury so it was directly available
get hasEverConnected(): boolean {
for (const ch of this.sessions.values()) {
if (ch.hasEverConnected) return true;
}

return false;
}

public registerAndConnect(
locusUrl: string,
datachannelUrl: string,
datachannelToken?: string,
sessionId: string = LLM_DEFAULT_SESSION
): Promise<void> {
const channel = this.getOrCreateSession(sessionId);

return channel.registerAndConnect(locusUrl, datachannelUrl, datachannelToken);
}

public disconnectLLM(
options: {code: number; reason: string},
// eslint-disable-next-line default-param-last
sessionId: string = LLM_DEFAULT_SESSION,
ownerMeetingId?: string
): Promise<boolean | void> {
const channel = this.getSession(sessionId);

if (!channel) return Promise.resolve();

const {isOwner} = this.resolveSessionOwnership(ownerMeetingId, sessionId);

if (!isOwner) {
this.logger.info(`llm#disconnectLLM --> skipping, not owner of session ${sessionId}`);

return Promise.resolve(false);
}

return channel.disconnect(options).then(() => {
this.sessions.delete(sessionId);

return true;
/**
* Registry of active LLM channels for interceptor lookup.
* Channels are registered when created and unregistered on disconnect.
*/
private channels = new Set<LLMChannel>();

/**
* Creates a new LLMChannel instance. The caller owns the channel and is
* responsible for connecting, disconnecting, and cleaning it up.
*
* @example
* const llm = webex.internal.llm.createConnection();
* llm.setRefreshHandler(() => meeting.refreshDataChannelToken());
* await llm.registerAndConnect(locusUrl, datachannelUrl, token);
*
* // Subscribe to events directly on the channel
* llm.on('event:relay.event', handler);
* llm.on('online', onlineHandler);
*
* // When done
* await llm.disconnect();
*
* @returns {LLMChannel} A new LLM connection instance
*/
public createConnection(): LLMChannel {
// @ts-ignore — WebexPlugin children require {parent: this.webex}
const channel = new LLMChannel({parent: this.webex});

this.channels.add(channel);

// Auto-unregister when disconnected
channel.on('offline', () => {
this.channels.delete(channel);
});
}

public disconnectAllLLM(options?: {code: number; reason: string}): Promise<void> {
const promises = Array.from(this.sessions.entries()).map(([sessionId, channel]) =>
channel.disconnect(options).then(() => this.sessions.delete(sessionId))
);

return Promise.all(promises).then(() => undefined);
}

public isConnected(sessionId: string = LLM_DEFAULT_SESSION): boolean {
const session = this.getSession(sessionId);
const connected = session?.isConnected() ?? false;

return connected;
}

public getBinding(sessionId: string = LLM_DEFAULT_SESSION): string | undefined {
return this.getSession(sessionId)?.getBinding();
}

public getSocket(sessionId: string = LLM_DEFAULT_SESSION): any {
return this.getSession(sessionId)?.socket;
}

// Backwards-compatibility: callers that access llm.socket directly
// (e.g. voicea's getPublishTransport) get the default session socket.
get socket(): any {
return this.getSocket(LLM_DEFAULT_SESSION);
}

public getLocusUrl(sessionId: string = LLM_DEFAULT_SESSION): string | undefined {
return this.getSession(sessionId)?.getLocusUrl();
}

public getDatachannelUrl(sessionId: string = LLM_DEFAULT_SESSION): string | undefined {
return this.getSession(sessionId)?.getDatachannelUrl();
}

// tokenKey IS the sessionId (DataChannelTokenType enum values equal LLM_*_SESSION constants)

public getDatachannelToken(
// eslint-disable-next-line default-param-last
tokenKey: string = LLM_DEFAULT_SESSION,
ownerMeetingId?: string
): string | undefined {
const channel = this.getSession(tokenKey);

if (!channel) return undefined;

const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey);

if (!isOwner) {
this.logger.info(
`llm#getDatachannelToken --> skip read for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
);

return undefined;
}

return channel.getDatachannelToken();
}

public setDatachannelToken(
datachannelToken: string,
ownerMeetingId?: string,
tokenKey: string = LLM_DEFAULT_SESSION
): void {
const channel = this.getOrCreateSession(tokenKey);
const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey);

if (!isOwner) {
this.logger.info(
`llm#setDatachannelToken --> skip write for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
);

return;
}

channel.setDatachannelToken(datachannelToken);
}

public clearDatachannelToken(tokenKey: string, ownerMeetingId: string): void {
const channel = this.getSession(tokenKey);

if (!channel) return;

const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey);

if (!isOwner) {
this.logger.info(
`llm#clearDatachannelToken --> skip clear for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
);

return;
}

channel.clearDatachannelToken();
}

public setRefreshHandler(
handler: () => Promise<{
body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
}>,
ownerMeetingId?: string,
sessionId: string = LLM_DEFAULT_SESSION
): void {
const channel = this.getOrCreateSession(sessionId);
const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, sessionId);

if (!isOwner) {
this.logger.info(
`llm#setRefreshHandler --> skip write for session ${sessionId}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
);

return;
}

channel.setRefreshHandler(handler);
}

public refreshDataChannelToken(sessionId: string = LLM_DEFAULT_SESSION): Promise<any> {
const channel = this.getSession(sessionId);

if (!channel) {
this.logger.warn(`llm#refreshDataChannelToken --> no channel for session ${sessionId}`);

return Promise.resolve(null);
}

return channel.refreshDataChannelToken();
}

public setOwnerMeetingId(
ownerMeetingId: string | undefined,
sessionId: string = LLM_DEFAULT_SESSION
): void {
const channel = this.getSession(sessionId);

if (channel) channel.ownerMeetingId = ownerMeetingId;
}

public getOwnerMeetingId(sessionId: string = LLM_DEFAULT_SESSION): string | undefined {
return this.getSession(sessionId)?.ownerMeetingId;
return channel;
}

public resolveSessionOwnership(
ownerMeetingId?: string,
sessionId: string = LLM_DEFAULT_SESSION
): {currentOwner: string | undefined; isOwner: boolean} {
const currentOwner = this.getOwnerMeetingId(sessionId);
const isOwner = !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId;

return {currentOwner, isOwner};
/**
* Returns true if the data channel token feature flag is enabled.
* This is a global check, not per-connection.
* @returns {Promise<boolean>}
*/
public isDataChannelTokenEnabled(): Promise<boolean> {
// @ts-ignore
return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN);
}

/**
* Find a connection by its datachannel URL. Used by the interceptor to
* route token refresh requests to the correct channel.
* @param {string} url - The request URL to match
* @returns {LLMChannel | undefined}
*/
public getConnectionByDatachannelUrl(url: string): LLMChannel | undefined {
for (const channel of this.sessions.values()) {
for (const channel of this.channels) {
const datachannelUrl = channel.getDatachannelUrl();

if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(url, datachannelUrl)) {
Expand All @@ -260,37 +93,37 @@ export class LLMPlugin extends (WebexPlugin as any) {
return undefined;
}

/**
* Find a locus URL by matching a request URL to an active connection's
* datachannel URL. Used by the interceptor.
* @param {string} requestUrl - The request URL to match
* @returns {string | undefined}
*/
public getLocusUrlByDatachannelUrl(requestUrl: string): string | undefined {
for (const channel of this.sessions.values()) {
const datachannelUrl = channel.getDatachannelUrl();
const channel = this.getConnectionByDatachannelUrl(requestUrl);

if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(requestUrl, datachannelUrl)) {
return channel.getLocusUrl();
}
}

return undefined;
return channel?.getLocusUrl();
}

public getSessionIdByDatachannelUrl(requestUrl: string): string | undefined {
for (const [sessionId, channel] of this.sessions.entries()) {
const datachannelUrl = channel.getDatachannelUrl();

if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(requestUrl, datachannelUrl)) {
return sessionId;
}
}

return undefined;
/**
* Get all active connections. Useful for diagnostics/debugging.
* @returns {Set<LLMChannel>}
*/
public getAllConnections(): Set<LLMChannel> {
return new Set(this.channels);
}

public isDataChannelTokenEnabled(): Promise<boolean> {
// @ts-ignore
return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN);
}
/**
* Disconnect all active connections. Useful for cleanup on logout.
* @param {object} [options] - Disconnect options
* @param {number} [options.code] - WebSocket close code
* @param {string} [options.reason] - WebSocket close reason
* @returns {Promise<void>}
*/
public async disconnectAll(options?: {code: number; reason: string}): Promise<void> {
const promises = Array.from(this.channels).map((channel) => channel.disconnect(options));

public getAllConnections(): Map<string, LLMChannel> {
return new Map(this.sessions);
await Promise.all(promises);
}
}

Expand Down
Loading