Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
import type { AuthenticationSession } from 'vscode';
8
import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication';
9
import { ConfigKey } from '../../../../../platform/configuration/common/configurationService';
10
import { DefaultsOnlyConfigurationService } from '../../../../../platform/configuration/common/defaultsOnlyConfigurationService';
11
import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';
12
import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';
13
import { ILogService } from '../../../../../platform/log/common/logService';
14
import { Emitter } from '../../../../../util/vs/base/common/event';
15
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
16
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
17
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
18
import { CopilotCLIModels, type CopilotCLIModelInfo, type ICopilotCLISDK } from '../copilotCli';
19
20
function createMockExtensionContext(): IVSCodeExtensionContext {
21
const state = new Map<string, unknown>();
22
return {
23
extensionPath: '/mock',
24
globalState: {
25
get: <T>(key: string, defaultValue?: T) => (state.get(key) as T) ?? defaultValue,
26
update: async (key: string, value: unknown) => { state.set(key, value); },
27
keys: () => [...state.keys()]
28
},
29
workspaceState: {
30
get: () => ({}),
31
update: async () => { },
32
keys: () => []
33
}
34
} as unknown as IVSCodeExtensionContext;
35
}
36
37
const FAKE_MODELS: CopilotCLIModelInfo[] = [
38
{ id: 'gpt-4o', name: 'GPT-4o', maxContextWindowTokens: 128000, supportsVision: true },
39
{ id: 'gpt-3.5', name: 'GPT-3.5', maxContextWindowTokens: 16000, supportsVision: false },
40
];
41
42
function createMockSDK(models: CopilotCLIModelInfo[] = FAKE_MODELS): ICopilotCLISDK {
43
return {
44
_serviceBrand: undefined,
45
getPackage: vi.fn(async () => ({
46
getAvailableModels: vi.fn(async () => models.map(m => ({
47
id: m.id,
48
name: m.name,
49
billing: m.multiplier !== undefined ? { multiplier: m.multiplier } : undefined,
50
capabilities: {
51
limits: {
52
max_prompt_tokens: m.maxInputTokens,
53
max_output_tokens: m.maxOutputTokens,
54
max_context_window_tokens: m.maxContextWindowTokens,
55
},
56
supports: { vision: m.supportsVision }
57
}
58
}))),
59
})),
60
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),
61
getRequestId: vi.fn(() => undefined),
62
setRequestId: vi.fn(),
63
} as unknown as ICopilotCLISDK;
64
}
65
66
class MockAuthenticationService {
67
private readonly _onDidAuthenticationChange = new Emitter<void>();
68
readonly onDidAuthenticationChange = this._onDidAuthenticationChange.event;
69
70
private _anyGitHubSession: AuthenticationSession | undefined;
71
72
constructor(hasSession: boolean) {
73
this._anyGitHubSession = hasSession
74
? { id: 'test', accessToken: 'token', scopes: [], account: { id: 'user', label: 'User' } }
75
: undefined;
76
}
77
78
get anyGitHubSession(): AuthenticationSession | undefined {
79
return this._anyGitHubSession;
80
}
81
82
setSession(session: AuthenticationSession | undefined): void {
83
this._anyGitHubSession = session;
84
}
85
86
fireAuthenticationChange(): void {
87
this._onDidAuthenticationChange.fire();
88
}
89
90
dispose(): void {
91
this._onDidAuthenticationChange.dispose();
92
}
93
}
94
class MockConfigurationService extends InMemoryConfigurationService {
95
constructor() {
96
super(new DefaultsOnlyConfigurationService());
97
}
98
}
99
100
describe('CopilotCLIModels', () => {
101
const disposables = new DisposableStore();
102
let logService: ILogService;
103
104
beforeEach(() => {
105
const services = disposables.add(createExtensionUnitTestingServices());
106
const accessor = services.createTestingAccessor();
107
logService = accessor.get(ILogService);
108
accessor.get(IInstantiationService);
109
});
110
111
afterEach(() => {
112
disposables.clear();
113
});
114
115
function createModels(options: { hasSession?: boolean; sdk?: ICopilotCLISDK; configService?: MockConfigurationService } = {}): { models: CopilotCLIModels; auth: MockAuthenticationService; configService: MockConfigurationService } {
116
const auth = new MockAuthenticationService(options.hasSession ?? true);
117
const sdk = options.sdk ?? createMockSDK();
118
const extensionContext = createMockExtensionContext();
119
const configService = options.configService ?? new MockConfigurationService();
120
121
const models = new CopilotCLIModels(
122
sdk,
123
extensionContext,
124
logService,
125
auth as unknown as IAuthenticationService,
126
configService
127
);
128
disposables.add(models);
129
disposables.add({ dispose: () => auth.dispose() });
130
return { models, auth, configService };
131
}
132
133
describe('getModels', () => {
134
it('returns empty array when no GitHub session exists', async () => {
135
const { models } = createModels({ hasSession: false });
136
137
const result = await models.getModels();
138
139
expect(result).toEqual([]);
140
});
141
142
it('returns models when GitHub session exists', async () => {
143
const { models } = createModels({ hasSession: true });
144
145
const result = await models.getModels();
146
147
expect(result.length).toBe(2);
148
expect(result[0].id).toBe('gpt-4o');
149
expect(result[1].id).toBe('gpt-3.5');
150
});
151
152
it('returns cached models on subsequent calls', async () => {
153
const sdk = createMockSDK();
154
const { models } = createModels({ hasSession: true, sdk });
155
156
const first = await models.getModels();
157
const second = await models.getModels();
158
159
expect(first).toBe(second);
160
// getPackage is called during constructor's eager fetch and at most once more
161
expect(sdk.getPackage).toHaveBeenCalledTimes(1);
162
});
163
});
164
165
describe('resolveModel', () => {
166
it('returns undefined when no GitHub session exists', async () => {
167
const { models } = createModels({ hasSession: false });
168
169
const result = await models.resolveModel('gpt-4o');
170
171
expect(result).toBeUndefined();
172
});
173
174
it('resolves model by id (case-insensitive)', async () => {
175
const { models } = createModels({ hasSession: true });
176
177
expect(await models.resolveModel('GPT-4O')).toBe('gpt-4o');
178
expect(await models.resolveModel('gpt-4o')).toBe('gpt-4o');
179
});
180
181
it('resolves model by name (case-insensitive)', async () => {
182
const { models } = createModels({ hasSession: true });
183
184
expect(await models.resolveModel('GPT-3.5')).toBe('gpt-3.5');
185
});
186
187
it('returns undefined for unknown model', async () => {
188
const { models } = createModels({ hasSession: true });
189
190
expect(await models.resolveModel('nonexistent-model')).toBeUndefined();
191
});
192
193
it('resolves "auto" without querying SDK models', async () => {
194
const { models } = createModels({ hasSession: false });
195
196
// Even without a session, 'auto' resolves to itself
197
expect(await models.resolveModel('auto')).toBe('auto');
198
expect(await models.resolveModel('Auto')).toBe('Auto');
199
expect(await models.resolveModel('AUTO')).toBe('AUTO');
200
});
201
});
202
203
describe('getDefaultModel', () => {
204
it('returns undefined when no GitHub session exists', async () => {
205
const { models } = createModels({ hasSession: false });
206
207
const result = await models.getDefaultModel();
208
209
expect(result).toBeUndefined();
210
});
211
212
it('returns first model when no preference is stored', async () => {
213
const { models } = createModels({ hasSession: true });
214
215
const result = await models.getDefaultModel();
216
217
expect(result).toBe('gpt-4o');
218
});
219
220
it('returns preferred model when preference is stored', async () => {
221
const { models } = createModels({ hasSession: true });
222
223
await models.setDefaultModel('gpt-3.5');
224
const result = await models.getDefaultModel();
225
226
expect(result).toBe('gpt-3.5');
227
});
228
229
it('falls back to first model when stored preference is invalid', async () => {
230
const { models } = createModels({ hasSession: true });
231
232
await models.setDefaultModel('nonexistent-model');
233
const result = await models.getDefaultModel();
234
235
expect(result).toBe('gpt-4o');
236
});
237
});
238
239
describe('onDidAuthenticationChange', () => {
240
it('propagates authentication change events to language model provider', async () => {
241
const sdk = createMockSDK();
242
const auth = new MockAuthenticationService(true);
243
disposables.add({ dispose: () => auth.dispose() });
244
const extensionContext = createMockExtensionContext();
245
246
const models = new CopilotCLIModels(
247
sdk,
248
extensionContext,
249
logService,
250
auth as unknown as IAuthenticationService,
251
new MockConfigurationService()
252
);
253
disposables.add(models);
254
255
// Wait for the eager model fetch to complete
256
await models.getModels();
257
258
// Subscribe to the change event via registerLanguageModelChatProvider
259
// and capture the provider's event
260
let providerOnChangeEvent: any;
261
const lmMock = {
262
registerLanguageModelChatProvider: (_id: string, provider: any) => {
263
providerOnChangeEvent = provider.onDidChangeLanguageModelChatInformation;
264
return { dispose: () => { } };
265
}
266
};
267
models.registerLanguageModelChatProvider(lmMock as any);
268
269
// Now subscribe to the captured event
270
let fired = false;
271
disposables.add(providerOnChangeEvent(() => { fired = true; }));
272
273
// Fire auth change — should propagate through _onDidChange
274
auth.fireAuthenticationChange();
275
276
expect(fired).toBe(true);
277
});
278
279
it('returns models after session becomes available', async () => {
280
const { models, auth } = createModels({ hasSession: false });
281
282
// No session: no models
283
expect(await models.getModels()).toEqual([]);
284
285
// Set session and verify models are now available
286
auth.setSession({ id: 'test', accessToken: 'token', scopes: [], account: { id: 'user', label: 'User' } });
287
const result = await models.getModels();
288
expect(result.length).toBe(2);
289
});
290
291
it('invalidates model cache on auth change', async () => {
292
const sdk = createMockSDK();
293
const { models, auth } = createModels({ hasSession: true, sdk });
294
295
// Initial fetch
296
await models.getModels();
297
const initialCallCount = (sdk.getPackage as ReturnType<typeof vi.fn>).mock.calls.length;
298
299
// Fire auth change to invalidate the cache
300
auth.fireAuthenticationChange();
301
302
// Next getModels() call should re-fetch from the SDK
303
await models.getModels();
304
expect((sdk.getPackage as ReturnType<typeof vi.fn>).mock.calls.length).toBe(initialCallCount + 1);
305
});
306
307
it('returns fresh models after auth change', async () => {
308
const updatedModels: CopilotCLIModelInfo[] = [
309
{ id: 'claude-4', name: 'Claude 4', maxContextWindowTokens: 200000, supportsVision: true },
310
];
311
let callCount = 0;
312
const sdk = {
313
_serviceBrand: undefined,
314
getPackage: vi.fn(async () => ({
315
getAvailableModels: vi.fn(async () => {
316
const source = callCount++ === 0 ? FAKE_MODELS : updatedModels;
317
return source.map(m => ({
318
id: m.id,
319
name: m.name,
320
billing: m.multiplier !== undefined ? { multiplier: m.multiplier } : undefined,
321
capabilities: {
322
limits: {
323
max_prompt_tokens: m.maxInputTokens,
324
max_output_tokens: m.maxOutputTokens,
325
max_context_window_tokens: m.maxContextWindowTokens,
326
},
327
supports: { vision: m.supportsVision }
328
},
329
}));
330
}),
331
})),
332
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),
333
getRequestId: vi.fn(() => undefined),
334
setRequestId: vi.fn(),
335
} as unknown as ICopilotCLISDK;
336
337
const { models, auth } = createModels({ hasSession: true, sdk });
338
339
// First fetch returns FAKE_MODELS
340
const first = await models.getModels();
341
expect(first.length).toBe(2);
342
expect(first[0].id).toBe('gpt-4o');
343
344
// Auth change invalidates cache
345
auth.fireAuthenticationChange();
346
347
// Next fetch returns updated models
348
const second = await models.getModels();
349
expect(second.length).toBe(1);
350
expect(second[0].id).toBe('claude-4');
351
});
352
});
353
354
describe('provideLanguageModelChatInformation', () => {
355
function createLmMock() {
356
let capturedProvider: any;
357
return {
358
mock: {
359
registerLanguageModelChatProvider: (_id: string, provider: any) => {
360
capturedProvider = provider;
361
return { dispose: () => { } };
362
}
363
},
364
getProvider: () => capturedProvider,
365
};
366
}
367
368
it('always includes auto model in results', async () => {
369
const { models } = createModels({ hasSession: true });
370
const lm = createLmMock();
371
models.registerLanguageModelChatProvider(lm.mock as any);
372
373
// Wait for the eager fetch to complete
374
await models.getModels();
375
// Allow the _fetchAndCacheModels .then() to run
376
await new Promise(r => setTimeout(r, 0));
377
378
const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
379
expect(result[0]).toEqual(expect.objectContaining({ id: 'auto', name: 'Auto' }));
380
});
381
382
it('returns only auto when not authenticated', async () => {
383
const { models } = createModels({ hasSession: false });
384
const lm = createLmMock();
385
models.registerLanguageModelChatProvider(lm.mock as any);
386
387
// Allow microtasks to settle (the eager fetch will fail/return empty)
388
await new Promise(r => setTimeout(r, 0));
389
390
const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
391
expect(result).toEqual([expect.objectContaining({ id: 'auto', name: 'Auto' })]);
392
});
393
394
it('returns only auto while models are still being fetched', async () => {
395
// Create an SDK that never resolves
396
let resolveModels!: (models: any[]) => void;
397
const sdk = {
398
_serviceBrand: undefined,
399
getPackage: vi.fn(async () => ({
400
getAvailableModels: vi.fn(() => new Promise(resolve => { resolveModels = resolve; })),
401
})),
402
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),
403
getRequestId: vi.fn(() => undefined),
404
} as unknown as ICopilotCLISDK;
405
406
const { models } = createModels({ hasSession: true, sdk });
407
const lm = createLmMock();
408
models.registerLanguageModelChatProvider(lm.mock as any);
409
410
// Models are still pending — should only get auto
411
const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
412
expect(result).toEqual([expect.objectContaining({ id: 'auto', name: 'Auto' })]);
413
414
// Flush microtasks so getPackage()/getAuthInfo() resolve and getAvailableModels is called,
415
// which captures resolveModels.
416
await new Promise(r => setTimeout(r, 0));
417
418
// Now resolve the models and let promises settle
419
resolveModels(FAKE_MODELS.map(m => ({
420
id: m.id, name: m.name,
421
capabilities: { limits: { max_context_window_tokens: m.maxContextWindowTokens, max_prompt_tokens: m.maxInputTokens, max_output_tokens: m.maxOutputTokens }, supports: { vision: m.supportsVision } },
422
})));
423
await new Promise(r => setTimeout(r, 0));
424
425
const afterResolve = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
426
expect(afterResolve.length).toBe(3); // auto + 2 models
427
expect(afterResolve[0]).toEqual(expect.objectContaining({ id: 'auto' }));
428
expect(afterResolve[1]).toEqual(expect.objectContaining({ id: 'gpt-4o' }));
429
expect(afterResolve[2]).toEqual(expect.objectContaining({ id: 'gpt-3.5' }));
430
});
431
432
it('returns full model list with auto prepended after fetch completes', async () => {
433
const { models } = createModels({ hasSession: true });
434
const lm = createLmMock();
435
models.registerLanguageModelChatProvider(lm.mock as any);
436
437
// Wait for the eager fetch to complete
438
await models.getModels();
439
await new Promise(r => setTimeout(r, 0));
440
441
const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
442
expect(result.length).toBe(3); // auto + 2 models
443
expect(result.map((m: any) => m.id)).toEqual(['auto', 'gpt-4o', 'gpt-3.5']);
444
});
445
446
it('resets to auto-only after auth change, then recovers', async () => {
447
const { models, auth } = createModels({ hasSession: true });
448
const lm = createLmMock();
449
models.registerLanguageModelChatProvider(lm.mock as any);
450
451
// Wait for initial fetch
452
await models.getModels();
453
await new Promise(r => setTimeout(r, 0));
454
455
const beforeAuthChange = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
456
expect(beforeAuthChange.length).toBe(3);
457
458
// Fire auth change — caches are cleared
459
auth.fireAuthenticationChange();
460
461
// Immediately after auth change, _resolvedModelInfos is cleared but re-fetch is in flight.
462
// Before the re-fetch settles, we should get just auto.
463
// (The re-fetch is async so hasn't settled yet in the same microtask.)
464
const duringRefresh = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
465
// Could be auto-only or already refreshed depending on timing; at minimum auto is present
466
expect(duringRefresh[0]).toEqual(expect.objectContaining({ id: 'auto' }));
467
468
// Let the re-fetch settle
469
await models.getModels();
470
await new Promise(r => setTimeout(r, 0));
471
472
const afterRefresh = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
473
expect(afterRefresh.length).toBe(3);
474
expect(afterRefresh[0]).toEqual(expect.objectContaining({ id: 'auto' }));
475
});
476
477
it('fires onDidChange when models become available', async () => {
478
let resolveModels!: (models: any[]) => void;
479
const sdk = {
480
_serviceBrand: undefined,
481
getPackage: vi.fn(async () => ({
482
getAvailableModels: vi.fn(() => new Promise(resolve => { resolveModels = resolve; })),
483
})),
484
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),
485
getRequestId: vi.fn(() => undefined),
486
} as unknown as ICopilotCLISDK;
487
488
const { models } = createModels({ hasSession: true, sdk });
489
const lm = createLmMock();
490
models.registerLanguageModelChatProvider(lm.mock as any);
491
492
let changeCount = 0;
493
disposables.add(lm.getProvider().onDidChangeLanguageModelChatInformation(() => { changeCount++; }));
494
495
// Flush microtasks so getPackage()/getAuthInfo() resolve and getAvailableModels is called,
496
// which captures resolveModels.
497
await new Promise(r => setTimeout(r, 0));
498
499
// Resolve models
500
resolveModels(FAKE_MODELS.map(m => ({
501
id: m.id, name: m.name,
502
capabilities: { limits: { max_context_window_tokens: m.maxContextWindowTokens, max_prompt_tokens: m.maxInputTokens, max_output_tokens: m.maxOutputTokens }, supports: { vision: m.supportsVision } },
503
})));
504
await new Promise(r => setTimeout(r, 0));
505
506
expect(changeCount).toBeGreaterThan(0);
507
});
508
});
509
510
describe('CLIAutoModelEnabled setting', () => {
511
function createLmMock() {
512
let capturedProvider: any;
513
return {
514
mock: {
515
registerLanguageModelChatProvider: (_id: string, provider: any) => {
516
capturedProvider = provider;
517
return { dispose: () => { } };
518
}
519
},
520
getProvider: () => capturedProvider,
521
};
522
}
523
524
it('omits auto model from resolved list when disabled', async () => {
525
const configService = new MockConfigurationService();
526
await configService.setConfig(ConfigKey.Advanced.CLIAutoModelEnabled, false);
527
const { models } = createModels({ hasSession: true, configService });
528
const lm = createLmMock();
529
models.registerLanguageModelChatProvider(lm.mock as any);
530
531
await models.getModels();
532
await new Promise(r => setTimeout(r, 0));
533
534
const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
535
expect(result.every((m: any) => m.id !== 'auto')).toBe(true);
536
expect(result.length).toBe(2);
537
expect(result[0]).toEqual(expect.objectContaining({ id: 'gpt-4o' }));
538
});
539
540
it('returns empty list when not authenticated and auto model disabled', async () => {
541
const configService = new MockConfigurationService();
542
await configService.setConfig(ConfigKey.Advanced.CLIAutoModelEnabled, false);
543
const { models } = createModels({ hasSession: false, configService });
544
const lm = createLmMock();
545
models.registerLanguageModelChatProvider(lm.mock as any);
546
547
await new Promise(r => setTimeout(r, 0));
548
549
const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
550
expect(result).toEqual([]);
551
});
552
553
it('resolveModel does not short-circuit auto when disabled', async () => {
554
const configService = new MockConfigurationService();
555
await configService.setConfig(ConfigKey.Advanced.CLIAutoModelEnabled, false);
556
const { models } = createModels({ hasSession: true, configService });
557
558
// With the setting disabled, 'auto' is not a known model so resolveModel returns undefined
559
expect(await models.resolveModel('auto')).toBeUndefined();
560
});
561
562
it('includes auto model when setting is enabled (default)', async () => {
563
const { models } = createModels({ hasSession: true });
564
const lm = createLmMock();
565
models.registerLanguageModelChatProvider(lm.mock as any);
566
567
await models.getModels();
568
await new Promise(r => setTimeout(r, 0));
569
570
const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);
571
expect(result[0]).toEqual(expect.objectContaining({ id: 'auto' }));
572
expect(result.length).toBe(3); // auto + 2 models
573
});
574
});
575
576
describe('SDK error handling', () => {
577
it('returns empty array when SDK getAvailableModels throws', async () => {
578
const sdk = {
579
_serviceBrand: undefined,
580
getPackage: vi.fn(async () => ({
581
getAvailableModels: vi.fn(async () => { throw new Error('Network error'); }),
582
})),
583
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),
584
getRequestId: vi.fn(() => undefined),
585
setRequestId: vi.fn(),
586
} as unknown as ICopilotCLISDK;
587
588
const { models } = createModels({ hasSession: true, sdk });
589
590
const result = await models.getModels();
591
592
expect(result).toEqual([]);
593
});
594
});
595
});
596
597