@webex/internal-plugin-llm 3.12.0-next.19 → 3.12.0-next.20

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.
package/src/llm.types.ts CHANGED
@@ -1,3 +1,10 @@
1
+ export enum DataChannelTokenType {
2
+ Default = 'llm-default-session',
3
+ PracticeSession = 'llm-practice-session',
4
+ }
5
+
6
+ type DataChannelTokenKey = DataChannelTokenType | string;
7
+
1
8
  interface ILLMChannel {
2
9
  registerAndConnect: (
3
10
  locusUrl: string,
@@ -9,10 +16,43 @@ interface ILLMChannel {
9
16
  getBinding: (sessionId?: string) => string;
10
17
  getLocusUrl: (sessionId?: string) => string;
11
18
  getDatachannelUrl: (sessionId?: string) => string;
12
- disconnectLLM: (options: {code: number; reason: string}, sessionId?: string) => Promise<void>;
19
+ disconnectLLM: (
20
+ options: {code: number; reason: string},
21
+ sessionId?: string,
22
+ ownerMeetingId?: string
23
+ ) => Promise<boolean>;
13
24
  disconnectAllLLM: (options?: {code: number; reason: string}) => Promise<void>;
14
25
  setOwnerMeetingId: (ownerMeetingId: string | undefined, sessionId?: string) => void;
15
26
  getOwnerMeetingId: (sessionId?: string) => string | undefined;
27
+ resolveSessionOwnership: (
28
+ ownerMeetingId?: string,
29
+ sessionId?: string
30
+ ) => {
31
+ currentOwner: string | undefined;
32
+ isOwner: boolean;
33
+ };
34
+ getDatachannelToken: (
35
+ tokenKey?: DataChannelTokenKey,
36
+ ownerMeetingId?: string
37
+ ) => string | undefined;
38
+ setDatachannelToken: (
39
+ datachannelToken: string,
40
+ tokenKey?: DataChannelTokenKey,
41
+ ownerMeetingId?: string
42
+ ) => void;
43
+ clearDatachannelToken: (tokenKey: DataChannelTokenKey, ownerMeetingId: string) => void;
44
+ setRefreshHandler: (
45
+ handler: () => Promise<{
46
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
47
+ }>,
48
+ sessionId?: string,
49
+ ownerMeetingId?: string
50
+ ) => void;
51
+ refreshDataChannelToken: (sessionId?: string) => Promise<{
52
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
53
+ } | null>;
54
+ getLocusUrlByDatachannelUrl: (requestUrl: string) => string | undefined;
55
+ getSessionIdByDatachannelUrl: (requestUrl: string) => string | undefined;
16
56
  getAllConnections: () => Map<
17
57
  string,
18
58
  {
@@ -20,16 +60,10 @@ interface ILLMChannel {
20
60
  binding?: string;
21
61
  locusUrl?: string;
22
62
  datachannelUrl?: string;
23
- datachannelToken?: string;
24
63
  ownerMeetingId?: string;
25
64
  }
26
65
  >;
27
66
  }
28
67
 
29
- export enum DataChannelTokenType {
30
- Default = 'llm-default-session',
31
- PracticeSession = 'llm-practice-session',
32
- }
33
-
34
68
  // eslint-disable-next-line import/prefer-default-export
35
- export type {ILLMChannel};
69
+ export type {ILLMChannel, DataChannelTokenKey};
@@ -3,6 +3,7 @@ import {assert} from '@webex/test-helper-chai';
3
3
  import sinon from 'sinon';
4
4
  import Mercury from '@webex/internal-plugin-mercury';
5
5
  import LLMService from '@webex/internal-plugin-llm';
6
+ import LLMChannel from '@webex/internal-plugin-llm/src/llm';
6
7
 
7
8
  describe('plugin-llm', () => {
8
9
  const locusUrl = 'locusUrl';
@@ -277,23 +278,27 @@ describe('plugin-llm', () => {
277
278
  instance = {
278
279
  disconnect: jest.fn(() => Promise.resolve()),
279
280
  connections: new Map([
280
- ['llm-default-session', { foo: 'bar' }],
281
+ ['llm-default-session', { foo: 'bar', datachannelToken: 'session-token' }],
281
282
  ]),
282
- datachannelTokens: {
283
- 'llm-default-session': 'session-token',
284
- },
285
283
 
286
- disconnectLLM: function (options, sessionId = 'llm-default-session') {
284
+ disconnectLLM: function (
285
+ options,
286
+ sessionId = 'llm-default-session',
287
+ ownerMeetingId = 'meeting-1'
288
+ ) {
289
+ if (!ownerMeetingId) {
290
+ return Promise.reject(new Error('ownerMeetingId is required'));
291
+ }
292
+
287
293
  return this.disconnect(options, sessionId).then(() => {
288
294
  this.connections.delete(sessionId);
289
- this.datachannelTokens[sessionId] = undefined;
290
295
  });
291
296
  },
292
297
  };
293
298
  });
294
299
 
295
- it('calls disconnect and clears session connection + token', async () => {
296
- await instance.disconnectLLM({ code: 3000, reason: 'bye' });
300
+ it('calls disconnect and clears session connection (including token stored in session)', async () => {
301
+ await instance.disconnectLLM({ code: 3000, reason: 'bye' }, 'llm-default-session', 'meeting-1');
297
302
 
298
303
  expect(instance.disconnect).toHaveBeenCalledWith(
299
304
  { code: 3000, reason: 'bye' },
@@ -301,26 +306,102 @@ describe('plugin-llm', () => {
301
306
  );
302
307
 
303
308
  expect(instance.connections.has('llm-default-session')).toBe(false);
309
+ });
310
+
311
+ it('disconnectLLM supports legacy call with options only', async () => {
312
+ llmService.disconnect = sinon.stub().resolves(true);
313
+
314
+ await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 'llm-default-session');
315
+
316
+ const options = {code: 1000, reason: 'legacy'};
317
+ const disconnected = await llmService.disconnectLLM(options);
318
+
319
+ assert.equal(disconnected, true);
320
+ sinon.assert.calledOnceWithExactly(llmService.disconnect, options, 'llm-default-session');
321
+ assert.equal(llmService.getAllConnections().has('llm-default-session'), false);
322
+ });
323
+
324
+ it('disconnectLLM supports legacy call with options and sessionId', async () => {
325
+ llmService.disconnect = sinon.stub().resolves(true);
326
+
327
+ await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
328
+
329
+ const options = {code: 1000, reason: 'legacy'};
330
+ const disconnected = await llmService.disconnectLLM(options, 's1');
304
331
 
305
- expect(instance.datachannelTokens['llm-default-session']).toBeUndefined();
332
+ assert.equal(disconnected, true);
333
+ sinon.assert.calledOnceWithExactly(llmService.disconnect, options, 's1');
334
+ assert.equal(llmService.getAllConnections().has('s1'), false);
335
+ });
336
+
337
+ it('disconnectLLM treats null sessionId as default session', async () => {
338
+ llmService.disconnect = sinon.stub().resolves(true);
339
+
340
+ await llmService.registerAndConnect(
341
+ locusUrl,
342
+ datachannelUrl,
343
+ undefined,
344
+ 'llm-default-session'
345
+ );
346
+
347
+ const options = {code: 1000, reason: 'legacy-null-session'};
348
+ const disconnected = await llmService.disconnectLLM(options, null);
349
+
350
+ assert.equal(disconnected, true);
351
+ sinon.assert.calledOnceWithExactly(llmService.disconnect, options, 'llm-default-session');
352
+ assert.equal(llmService.getAllConnections().has('llm-default-session'), false);
306
353
  });
307
354
 
308
355
  it('propagates disconnect errors', async () => {
309
356
  instance.disconnect.mockRejectedValue(new Error('disconnect failed'));
310
357
 
311
358
  await expect(
312
- instance.disconnectLLM({ code: 3000, reason: 'bye' })
359
+ instance.disconnectLLM({ code: 3000, reason: 'bye' }, 'llm-default-session', 'meeting-1')
313
360
  ).rejects.toThrow('disconnect failed');
314
361
  });
315
362
  });
316
363
 
317
364
  describe('#setRefreshHandler', () => {
365
+ beforeEach(() => {
366
+ llmService.setRefreshHandler = LLMChannel.prototype.setRefreshHandler.bind(llmService);
367
+ llmService.refreshDataChannelToken = LLMChannel.prototype.refreshDataChannelToken.bind(llmService);
368
+ });
369
+
370
+ it('defaults to llm-default-session when sessionId is omitted', () => {
371
+ const handler = sinon.stub().resolves({ body: { datachannelToken: 'legacyToken' } });
372
+
373
+ llmService.setRefreshHandler(handler);
374
+
375
+ return llmService.refreshDataChannelToken().then((result) => {
376
+ assert.equal(result.body.datachannelToken, 'legacyToken');
377
+ sinon.assert.calledOnce(handler);
378
+ });
379
+ });
380
+
318
381
  it('stores the provided handler', () => {
319
382
  const handler = sinon.stub().resolves({ body: { datachannelToken: 'newToken' } });
320
- llmService.setRefreshHandler(handler);
383
+ llmService.setRefreshHandler(handler, 'llm-default-session');
384
+
385
+ return llmService.refreshDataChannelToken().then((result) => {
386
+ assert.equal(result.body.datachannelToken, 'newToken');
387
+ sinon.assert.calledOnce(handler);
388
+ });
389
+ });
390
+
391
+ it('stores handlers per session id', () => {
392
+ const handlerS1 = sinon.stub().resolves({ body: { datachannelToken: 'token-s1' } });
393
+ const handlerS2 = sinon.stub().resolves({ body: { datachannelToken: 'token-s2' } });
394
+
395
+ llmService.setRefreshHandler(handlerS1, 's1');
396
+ llmService.setRefreshHandler(handlerS2, 's2');
321
397
 
322
- // @ts-ignore
323
- assert.equal(llmService.refreshHandler, handler);
398
+ return Promise.all([
399
+ llmService.refreshDataChannelToken('s1'),
400
+ llmService.refreshDataChannelToken('s2'),
401
+ ]).then(([resultS1, resultS2]) => {
402
+ assert.equal(resultS1.body.datachannelToken, 'token-s1');
403
+ assert.equal(resultS2.body.datachannelToken, 'token-s2');
404
+ });
324
405
  });
325
406
  });
326
407
 
@@ -341,6 +422,11 @@ describe('plugin-llm', () => {
341
422
  });
342
423
 
343
424
  describe('#refreshDataChannelToken', () => {
425
+ beforeEach(() => {
426
+ llmService.setRefreshHandler = LLMChannel.prototype.setRefreshHandler.bind(llmService);
427
+ llmService.refreshDataChannelToken = LLMChannel.prototype.refreshDataChannelToken.bind(llmService);
428
+ });
429
+
344
430
  it('returns null and logs warn if no handler is set', async () => {
345
431
  const warnSpy = llmService.logger.warn
346
432
 
@@ -351,7 +437,7 @@ describe('plugin-llm', () => {
351
437
  sinon.assert.calledOnce(warnSpy);
352
438
  sinon.assert.calledWithMatch(
353
439
  warnSpy,
354
- sinon.match('LLM refreshHandler is not set')
440
+ sinon.match('LLM refreshHandler is not set for session')
355
441
  );
356
442
  });
357
443
 
@@ -359,7 +445,7 @@ describe('plugin-llm', () => {
359
445
  const mockToken = { body: { datachannelToken: 'newToken', isPracticeSession: false } };
360
446
  const handler = sinon.stub().resolves(mockToken);
361
447
 
362
- llmService.setRefreshHandler(handler);
448
+ llmService.setRefreshHandler(handler, 'llm-default-session');
363
449
 
364
450
  const token = await llmService.refreshDataChannelToken();
365
451
 
@@ -367,9 +453,21 @@ describe('plugin-llm', () => {
367
453
  sinon.assert.calledOnce(handler);
368
454
  });
369
455
 
456
+ it('uses the handler for the provided session id', async () => {
457
+ const mockToken = { body: { datachannelToken: 'session-token', isPracticeSession: true } };
458
+ const handler = sinon.stub().resolves(mockToken);
459
+
460
+ llmService.setRefreshHandler(handler, 's2');
461
+
462
+ const token = await llmService.refreshDataChannelToken('s2');
463
+
464
+ assert.equal(token, mockToken);
465
+ sinon.assert.calledOnce(handler);
466
+ });
467
+
370
468
  it('logs warn and returns null when handler rejects', async () => {
371
469
  const handler = sinon.stub().rejects(new Error('throw error'));
372
- llmService.setRefreshHandler(handler);
470
+ llmService.setRefreshHandler(handler, 'llm-default-session');
373
471
 
374
472
  const warnSpy = llmService.logger.warn
375
473
 
@@ -392,6 +490,13 @@ describe('plugin-llm', () => {
392
490
  llmService.setDatachannelToken('123abc','llm-practice-session');
393
491
  assert.equal(llmService.getDatachannelToken('llm-practice-session'), '123abc');
394
492
  });
493
+
494
+ it('supports arbitrary session id token keys', () => {
495
+ llmService.setDatachannelToken('token-s1', 'session-custom-1');
496
+
497
+ assert.equal(llmService.getDatachannelToken('session-custom-1'), 'token-s1');
498
+ });
499
+
395
500
  });
396
501
 
397
502
  describe('#setOwnerMeetingId / #getOwnerMeetingId', () => {
@@ -443,7 +548,11 @@ describe('plugin-llm', () => {
443
548
  llmService.setOwnerMeetingId('meeting-1');
444
549
  assert.equal(llmService.getOwnerMeetingId(), 'meeting-1');
445
550
 
446
- await llmService.disconnectLLM({code: 3050, reason: 'done (permanent)'});
551
+ await llmService.disconnectLLM(
552
+ {code: 3050, reason: 'done (permanent)'},
553
+ 'llm-default-session',
554
+ 'meeting-1'
555
+ );
447
556
 
448
557
  // Session entry was deleted, so ownerMeetingId is gone.
449
558
  assert.equal(llmService.getOwnerMeetingId(), undefined);
@@ -477,20 +586,33 @@ describe('plugin-llm', () => {
477
586
  await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
478
587
 
479
588
  const options = {code: 1000, reason: 'test'};
480
- await llmService.disconnectLLM(options, 's1');
589
+ const disconnected = await llmService.disconnectLLM(options, 's1', 'meeting-1');
481
590
 
591
+ assert.equal(disconnected, true);
482
592
  sinon.assert.calledOnceWithExactly(llmService.disconnect, options, 's1');
483
593
 
484
594
  const all = llmService.getAllConnections();
485
595
  assert.equal(all.has('s1'), false);
486
596
  assert.equal(all.has('s2'), true);
597
+ assert.equal(llmService.getDatachannelToken('s1'), undefined);
598
+ });
599
+
600
+ it('disconnectLLM skips disconnect when ownerMeetingId does not match', async () => {
601
+ llmService.disconnect = sinon.stub().resolves(true);
602
+
603
+ await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
604
+ llmService.setOwnerMeetingId('meeting-1', 's1');
605
+
606
+ const options = {code: 1000, reason: 'test'};
607
+ const disconnected = await llmService.disconnectLLM(options, 's1', 'meeting-2');
487
608
 
488
- assert.equal(llmService.datachannelTokens['s1'], undefined);
609
+ assert.equal(disconnected, false);
610
+ sinon.assert.notCalled(llmService.disconnect);
611
+ assert.equal(llmService.getAllConnections().has('s1'), true);
489
612
  });
490
613
 
491
614
  it('disconnectAllLLM clears all sessions', async () => {
492
615
  llmService.disconnectAll = sinon.stub().resolves(true);
493
- sinon.spy(llmService, 'resetDatachannelTokens');
494
616
 
495
617
  await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
496
618
  await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
@@ -502,5 +624,90 @@ describe('plugin-llm', () => {
502
624
  });
503
625
  });
504
626
 
627
+ describe('#getLocusUrlByDatachannelUrl', () => {
628
+ const locusUrl2 = 'https://locus-b.wbx2.com/locus/api/v1/loci/456';
629
+ const datachannelUrl2 = 'https://board-b.wbx2.com/datachannel/api/v1/locus/ps-encoded/registrations';
630
+
631
+ // Ampersand State.extend() does not always propagate prototype methods
632
+ // added to child classes, so we bind the method from the class prototype
633
+ // directly onto the test instance.
634
+ beforeEach(() => {
635
+ llmService.getLocusUrlByDatachannelUrl = LLMChannel.prototype.getLocusUrlByDatachannelUrl.bind(llmService);
636
+ });
637
+
638
+ it('returns locusUrl when request URL matches a session datachannelUrl', async () => {
639
+ await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
640
+ await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
641
+
642
+ const result = llmService.getLocusUrlByDatachannelUrl(datachannelUrl2 + '/some-path');
643
+
644
+ assert.equal(result, locusUrl2);
645
+ });
646
+
647
+ it('returns undefined when no session matches the request URL', async () => {
648
+ await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
649
+
650
+ const result = llmService.getLocusUrlByDatachannelUrl('https://unknown.example.com/path');
651
+
652
+ assert.equal(result, undefined);
653
+ });
654
+
655
+ it('matches locusUrl when request host is rewritten but pathname matches', async () => {
656
+ await llmService.registerAndConnect(locusUrl2, datachannelUrl2, undefined, 's2');
657
+
658
+ const rewrittenHostRequestUrl =
659
+ 'https://hostmap-rewritten.example.com/datachannel/api/v1/locus/ps-encoded/registrations/events';
660
+ const result = llmService.getLocusUrlByDatachannelUrl(rewrittenHostRequestUrl);
661
+
662
+ assert.equal(result, locusUrl2);
663
+ });
664
+
665
+ it('returns undefined when no connections exist', () => {
666
+ const result = llmService.getLocusUrlByDatachannelUrl(datachannelUrl);
667
+
668
+ assert.equal(result, undefined);
669
+ });
670
+ });
671
+
672
+ describe('#getSessionIdByDatachannelUrl', () => {
673
+ const datachannelUrl2 = 'https://board-b.wbx2.com/datachannel/api/v1/locus/ps-encoded/registrations';
674
+
675
+ beforeEach(() => {
676
+ llmService.getSessionIdByDatachannelUrl = LLMChannel.prototype.getSessionIdByDatachannelUrl.bind(llmService);
677
+ });
678
+
679
+ it('returns sessionId when request URL matches a session datachannelUrl', async () => {
680
+ await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
681
+ await llmService.registerAndConnect('https://locus-b.wbx2.com/locus/api/v1/loci/456', datachannelUrl2, undefined, 's2');
682
+
683
+ const result = llmService.getSessionIdByDatachannelUrl(datachannelUrl2 + '/some-path');
684
+
685
+ assert.equal(result, 's2');
686
+ });
687
+
688
+ it('returns undefined when no session matches the request URL', async () => {
689
+ await llmService.registerAndConnect(locusUrl, datachannelUrl, undefined, 's1');
690
+
691
+ const result = llmService.getSessionIdByDatachannelUrl('https://unknown.example.com/path');
692
+
693
+ assert.equal(result, undefined);
694
+ });
695
+
696
+ it('matches sessionId when request host is rewritten but pathname matches', async () => {
697
+ await llmService.registerAndConnect(
698
+ 'https://locus-b.wbx2.com/locus/api/v1/loci/456',
699
+ datachannelUrl2,
700
+ undefined,
701
+ 's2'
702
+ );
703
+
704
+ const rewrittenHostRequestUrl =
705
+ 'https://hostmap-rewritten.example.com/datachannel/api/v1/locus/ps-encoded/registrations/messages';
706
+ const result = llmService.getSessionIdByDatachannelUrl(rewrittenHostRequestUrl);
707
+
708
+ assert.equal(result, 's2');
709
+ });
710
+ });
711
+
505
712
  });
506
713
  });