Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts
4780 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 { distinct } from '../../../../../base/common/arrays.js';
7
import { IMatch, IFilter, or, matchesCamelCase, matchesWords, matchesBaseContiguousSubString } from '../../../../../base/common/filters.js';
8
import { Emitter } from '../../../../../base/common/event.js';
9
import { ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelChatMetadataAndIdentifier } from '../../../chat/common/languageModels.js';
10
import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js';
11
import { localize } from '../../../../../nls.js';
12
import { Disposable } from '../../../../../base/common/lifecycle.js';
13
import { ILanguageModelsProviderGroup, ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js';
14
import { Throttler } from '../../../../../base/common/async.js';
15
import Severity from '../../../../../base/common/severity.js';
16
17
export const MODEL_ENTRY_TEMPLATE_ID = 'model.entry.template';
18
export const VENDOR_ENTRY_TEMPLATE_ID = 'vendor.entry.template';
19
export const GROUP_ENTRY_TEMPLATE_ID = 'group.entry.template';
20
21
const wordFilter = or(matchesBaseContiguousSubString, matchesWords);
22
const CAPABILITY_REGEX = /@capability:\s*([^\s]+)/gi;
23
const VISIBLE_REGEX = /@visible:\s*(true|false)/i;
24
const PROVIDER_REGEX = /@provider:\s*((".+?")|([^\s]+))/gi;
25
26
export const SEARCH_SUGGESTIONS = {
27
FILTER_TYPES: [
28
'@provider:',
29
'@capability:',
30
'@visible:'
31
],
32
CAPABILITIES: [
33
'@capability:tools',
34
'@capability:vision',
35
'@capability:agent'
36
],
37
VISIBILITY: [
38
'@visible:true',
39
'@visible:false'
40
]
41
};
42
43
export interface ILanguageModelProvider {
44
vendor: IUserFriendlyLanguageModel;
45
group: ILanguageModelsProviderGroup;
46
}
47
48
export interface ILanguageModel extends ILanguageModelChatMetadataAndIdentifier {
49
provider: ILanguageModelProvider;
50
}
51
52
export interface ILanguageModelEntry {
53
type: 'model';
54
id: string;
55
templateId: string;
56
model: ILanguageModel;
57
providerMatches?: IMatch[];
58
modelNameMatches?: IMatch[];
59
modelIdMatches?: IMatch[];
60
capabilityMatches?: string[];
61
}
62
63
export interface ILanguageModelGroupEntry {
64
type: 'group';
65
id: string;
66
label: string;
67
collapsed: boolean;
68
templateId: string;
69
}
70
71
export interface ILanguageModelProviderEntry {
72
type: 'vendor';
73
id: string;
74
label: string;
75
templateId: string;
76
collapsed: boolean;
77
vendorEntry: ILanguageModelProvider;
78
}
79
80
export interface IStatusEntry {
81
type: 'status';
82
id: string;
83
message: string;
84
severity: Severity;
85
}
86
87
export interface ILanguageModelEntriesGroup {
88
group: ILanguageModelGroupEntry | ILanguageModelProviderEntry;
89
models: ILanguageModel[];
90
status?: IStatusEntry;
91
}
92
93
export function isLanguageModelProviderEntry(entry: IViewModelEntry): entry is ILanguageModelProviderEntry {
94
return entry.type === 'vendor';
95
}
96
97
export function isLanguageModelGroupEntry(entry: IViewModelEntry): entry is ILanguageModelGroupEntry {
98
return entry.type === 'group';
99
}
100
101
export function isStatusEntry(entry: IViewModelEntry): entry is IStatusEntry {
102
return entry.type === 'status';
103
}
104
105
export type IViewModelEntry = ILanguageModelEntry | ILanguageModelProviderEntry | ILanguageModelGroupEntry | IStatusEntry;
106
107
export interface IViewModelChangeEvent {
108
at: number;
109
removed: number;
110
added: IViewModelEntry[];
111
}
112
113
export const enum ChatModelGroup {
114
Vendor = 'vendor',
115
Visibility = 'visibility'
116
}
117
118
export class ChatModelsViewModel extends Disposable {
119
120
private readonly _onDidChange = this._register(new Emitter<IViewModelChangeEvent>());
121
readonly onDidChange = this._onDidChange.event;
122
123
private readonly _onDidChangeGrouping = this._register(new Emitter<ChatModelGroup>());
124
readonly onDidChangeGrouping = this._onDidChangeGrouping.event;
125
126
private languageModels: ILanguageModel[];
127
private languageModelGroupStatuses: Array<{ provider: ILanguageModelProvider; status: { severity: Severity; message: string } }> = [];
128
private languageModelGroups: ILanguageModelEntriesGroup[] = [];
129
130
private readonly collapsedGroups = new Set<string>();
131
private searchValue: string = '';
132
private modelsSorted: boolean = false;
133
134
private _groupBy: ChatModelGroup = ChatModelGroup.Vendor;
135
get groupBy(): ChatModelGroup { return this._groupBy; }
136
set groupBy(groupBy: ChatModelGroup) {
137
if (this._groupBy !== groupBy) {
138
this._groupBy = groupBy;
139
this.collapsedGroups.clear();
140
this.languageModelGroups = this.groupModels(this.languageModels);
141
this.doFilter();
142
this._onDidChangeGrouping.fire(groupBy);
143
}
144
}
145
146
private readonly refreshThrottler = this._register(new Throttler());
147
148
constructor(
149
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
150
@ILanguageModelsConfigurationService private readonly languageModelsConfigurationService: ILanguageModelsConfigurationService,
151
@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService
152
) {
153
super();
154
this.languageModels = [];
155
this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.refresh()));
156
this._register(this.languageModelsConfigurationService.onDidChangeLanguageModelGroups(() => this.refresh()));
157
}
158
159
private readonly _viewModelEntries: IViewModelEntry[] = [];
160
get viewModelEntries(): readonly IViewModelEntry[] {
161
return this._viewModelEntries;
162
}
163
private splice(at: number, removed: number, added: IViewModelEntry[]): void {
164
this._viewModelEntries.splice(at, removed, ...added);
165
if (this.selectedEntry) {
166
this.selectedEntry = this._viewModelEntries.find(entry => entry.id === this.selectedEntry?.id);
167
}
168
this._onDidChange.fire({ at, removed, added });
169
}
170
171
selectedEntry: IViewModelEntry | undefined;
172
173
public shouldRefilter(): boolean {
174
return !this.modelsSorted;
175
}
176
177
filter(searchValue: string): readonly IViewModelEntry[] {
178
if (searchValue !== this.searchValue) {
179
this.collapsedGroups.clear();
180
}
181
this.searchValue = searchValue;
182
if (!this.modelsSorted) {
183
this.languageModelGroups = this.groupModels(this.languageModels);
184
}
185
186
this.doFilter();
187
return this.viewModelEntries;
188
}
189
190
private doFilter(): void {
191
const viewModelEntries: IViewModelEntry[] = [];
192
const shouldShowGroupHeaders = this.languageModelGroups.length > 1;
193
194
for (const group of this.languageModelGroups) {
195
if (this.collapsedGroups.has(group.group.id)) {
196
group.group.collapsed = true;
197
if (shouldShowGroupHeaders) {
198
viewModelEntries.push(group.group);
199
}
200
continue;
201
}
202
203
const groupEntries: IViewModelEntry[] = [];
204
if (group.status) {
205
groupEntries.push(group.status);
206
}
207
208
groupEntries.push(...this.filterModels(group.models, this.searchValue));
209
210
if (groupEntries.length > 0) {
211
group.group.collapsed = false;
212
if (shouldShowGroupHeaders) {
213
viewModelEntries.push(group.group);
214
}
215
viewModelEntries.push(...groupEntries);
216
}
217
}
218
this.splice(0, this._viewModelEntries.length, viewModelEntries);
219
}
220
221
private filterModels(modelEntries: ILanguageModel[], searchValue: string): IViewModelEntry[] {
222
let visible: boolean | undefined;
223
224
const visibleMatches = VISIBLE_REGEX.exec(searchValue);
225
if (visibleMatches && visibleMatches[1]) {
226
visible = visibleMatches[1].toLowerCase() === 'true';
227
searchValue = searchValue.replace(VISIBLE_REGEX, '');
228
}
229
230
const providerNames: string[] = [];
231
let providerMatch: RegExpExecArray | null;
232
PROVIDER_REGEX.lastIndex = 0;
233
while ((providerMatch = PROVIDER_REGEX.exec(searchValue)) !== null) {
234
const providerName = providerMatch[2] ? providerMatch[2].substring(1, providerMatch[2].length - 1) : providerMatch[3];
235
providerNames.push(providerName);
236
}
237
if (providerNames.length > 0) {
238
searchValue = searchValue.replace(PROVIDER_REGEX, '');
239
}
240
241
const capabilities: string[] = [];
242
let capabilityMatch: RegExpExecArray | null;
243
CAPABILITY_REGEX.lastIndex = 0;
244
while ((capabilityMatch = CAPABILITY_REGEX.exec(searchValue)) !== null) {
245
capabilities.push(capabilityMatch[1].toLowerCase());
246
}
247
if (capabilities.length > 0) {
248
searchValue = searchValue.replace(CAPABILITY_REGEX, '');
249
}
250
251
const quoteAtFirstChar = searchValue.charAt(0) === '"';
252
const quoteAtLastChar = searchValue.charAt(searchValue.length - 1) === '"';
253
const completeMatch = quoteAtFirstChar && quoteAtLastChar;
254
if (quoteAtFirstChar) {
255
searchValue = searchValue.substring(1);
256
}
257
if (quoteAtLastChar) {
258
searchValue = searchValue.substring(0, searchValue.length - 1);
259
}
260
searchValue = searchValue.trim();
261
262
const result: IViewModelEntry[] = [];
263
const words = searchValue.split(' ');
264
const lowerProviders = providerNames.map(p => p.toLowerCase().trim());
265
266
for (const modelEntry of modelEntries) {
267
if (visible !== undefined) {
268
if ((modelEntry.metadata.isUserSelectable ?? false) !== visible) {
269
continue;
270
}
271
}
272
273
if (lowerProviders.length > 0) {
274
const matchesProvider = lowerProviders.some(provider =>
275
modelEntry.provider.vendor.vendor.toLowerCase() === provider ||
276
modelEntry.provider.vendor.displayName.toLowerCase() === provider
277
);
278
if (!matchesProvider) {
279
continue;
280
}
281
}
282
283
// Filter by capabilities
284
let matchedCapabilities: string[] = [];
285
if (capabilities.length > 0) {
286
if (!modelEntry.metadata.capabilities) {
287
continue;
288
}
289
let matchesAll = true;
290
for (const capability of capabilities) {
291
const matchedForThisCapability = this.getMatchingCapabilities(modelEntry, capability);
292
if (matchedForThisCapability.length === 0) {
293
matchesAll = false;
294
break;
295
}
296
matchedCapabilities.push(...matchedForThisCapability);
297
}
298
if (!matchesAll) {
299
continue;
300
}
301
matchedCapabilities = distinct(matchedCapabilities);
302
}
303
304
// Filter by text
305
let modelMatches: ModelItemMatches | undefined;
306
if (searchValue) {
307
modelMatches = new ModelItemMatches(modelEntry, searchValue, words, completeMatch);
308
if (!modelMatches.modelNameMatches && !modelMatches.modelIdMatches && !modelMatches.providerMatches && !modelMatches.capabilityMatches) {
309
continue;
310
}
311
}
312
313
const modelId = this.getModelId(modelEntry);
314
result.push({
315
type: 'model',
316
id: modelId,
317
templateId: MODEL_ENTRY_TEMPLATE_ID,
318
model: modelEntry,
319
modelNameMatches: modelMatches?.modelNameMatches || undefined,
320
modelIdMatches: modelMatches?.modelIdMatches || undefined,
321
providerMatches: modelMatches?.providerMatches || undefined,
322
capabilityMatches: matchedCapabilities.length ? matchedCapabilities : undefined,
323
});
324
}
325
return result;
326
}
327
328
private getMatchingCapabilities(modelEntry: ILanguageModel, capability: string): string[] {
329
const matchedCapabilities: string[] = [];
330
if (!modelEntry.metadata.capabilities) {
331
return matchedCapabilities;
332
}
333
334
switch (capability) {
335
case 'tools':
336
case 'toolcalling':
337
if (modelEntry.metadata.capabilities.toolCalling === true) {
338
matchedCapabilities.push('toolCalling');
339
}
340
break;
341
case 'vision':
342
if (modelEntry.metadata.capabilities.vision === true) {
343
matchedCapabilities.push('vision');
344
}
345
break;
346
case 'agent':
347
case 'agentmode':
348
if (modelEntry.metadata.capabilities.agentMode === true) {
349
matchedCapabilities.push('agentMode');
350
}
351
break;
352
default:
353
// Check edit tools
354
if (modelEntry.metadata.capabilities.editTools) {
355
for (const tool of modelEntry.metadata.capabilities.editTools) {
356
if (tool.toLowerCase().includes(capability)) {
357
matchedCapabilities.push(tool);
358
}
359
}
360
}
361
break;
362
}
363
return matchedCapabilities;
364
}
365
366
private groupModels(languageModels: ILanguageModel[]): ILanguageModelEntriesGroup[] {
367
const result: ILanguageModelEntriesGroup[] = [];
368
if (this.groupBy === ChatModelGroup.Visibility) {
369
const visible = [], hidden = [];
370
for (const model of languageModels) {
371
if (model.metadata.isUserSelectable) {
372
visible.push(model);
373
} else {
374
hidden.push(model);
375
}
376
}
377
result.push({
378
group: {
379
type: 'group',
380
id: 'visible',
381
label: localize('visible', "Visible"),
382
templateId: GROUP_ENTRY_TEMPLATE_ID,
383
collapsed: this.collapsedGroups.has('visible')
384
},
385
models: visible
386
});
387
result.push({
388
group: {
389
type: 'group',
390
id: 'hidden',
391
label: localize('hidden', "Hidden"),
392
templateId: GROUP_ENTRY_TEMPLATE_ID,
393
collapsed: this.collapsedGroups.has('hidden'),
394
},
395
models: hidden
396
});
397
}
398
else if (this.groupBy === ChatModelGroup.Vendor) {
399
for (const model of languageModels) {
400
const groupId = this.getProviderGroupId(model.provider.group);
401
let group = result.find(group => group.group.id === groupId);
402
if (!group) {
403
group = {
404
group: this.createLanguageModelProviderEntry(model.provider),
405
models: [],
406
};
407
result.push(group);
408
}
409
group.models.push(model);
410
}
411
for (const statusGroup of this.languageModelGroupStatuses) {
412
const groupId = this.getProviderGroupId(statusGroup.provider.group);
413
let group = result.find(group => group.group.id === groupId);
414
if (!group) {
415
group = {
416
group: this.createLanguageModelProviderEntry(statusGroup.provider),
417
models: [],
418
};
419
result.push(group);
420
}
421
group.status = {
422
id: `status.${group.group.id}`,
423
type: 'status',
424
...statusGroup.status,
425
};
426
}
427
result.sort((a, b) => {
428
if (a.models[0]?.provider.vendor.vendor === 'copilot') { return -1; }
429
if (b.models[0]?.provider.vendor.vendor === 'copilot') { return 1; }
430
return a.group.label.localeCompare(b.group.label);
431
});
432
}
433
for (const group of result) {
434
group.models.sort((a, b) => {
435
if (a.provider.vendor.vendor === 'copilot' && b.provider.vendor.vendor === 'copilot') {
436
return a.metadata.name.localeCompare(b.metadata.name);
437
}
438
if (a.provider.vendor.vendor === 'copilot') { return -1; }
439
if (b.provider.vendor.vendor === 'copilot') { return 1; }
440
if (a.provider.group.name === b.provider.group.name) {
441
return a.metadata.name.localeCompare(b.metadata.name);
442
}
443
return a.provider.group.name.localeCompare(b.provider.group.name);
444
});
445
}
446
this.modelsSorted = true;
447
return result;
448
}
449
450
private createLanguageModelProviderEntry(provider: ILanguageModelProvider): ILanguageModelProviderEntry {
451
const id = this.getProviderGroupId(provider.group);
452
return {
453
type: 'vendor',
454
id,
455
label: provider.group.name,
456
templateId: VENDOR_ENTRY_TEMPLATE_ID,
457
collapsed: this.collapsedGroups.has(id),
458
vendorEntry: {
459
group: provider.group,
460
vendor: provider.vendor
461
},
462
};
463
}
464
465
getVendors(): IUserFriendlyLanguageModel[] {
466
return [...this.languageModelsService.getVendors()].sort((a, b) => {
467
if (a.vendor === 'copilot') { return -1; }
468
if (b.vendor === 'copilot') { return 1; }
469
return a.displayName.localeCompare(b.displayName);
470
});
471
}
472
473
refresh(): Promise<void> {
474
return this.refreshThrottler.queue(() => this.doRefresh());
475
}
476
477
private async doRefresh(): Promise<void> {
478
this.languageModels = [];
479
this.languageModelGroupStatuses = [];
480
for (const vendor of this.getVendors()) {
481
const models: ILanguageModel[] = [];
482
const languageModelsGroups = await this.languageModelsService.fetchLanguageModelGroups(vendor.vendor);
483
for (const group of languageModelsGroups) {
484
const provider: ILanguageModelProvider = {
485
group: group.group ?? {
486
vendor: vendor.vendor,
487
name: vendor.displayName
488
},
489
vendor
490
};
491
if (group.status) {
492
this.languageModelGroupStatuses.push({
493
provider,
494
status: {
495
message: group.status.message,
496
severity: group.status.severity
497
}
498
});
499
}
500
for (const model of group.models) {
501
if (vendor.vendor === 'copilot' && model.metadata.id === 'auto') {
502
continue;
503
}
504
models.push({
505
identifier: model.identifier,
506
metadata: model.metadata,
507
provider,
508
});
509
}
510
}
511
this.languageModels.push(...models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)));
512
this.languageModelGroups = this.groupModels(this.languageModels);
513
this.doFilter();
514
}
515
}
516
517
toggleVisibility(model: ILanguageModelEntry): void {
518
const isVisible = model.model.metadata.isUserSelectable ?? false;
519
const newVisibility = !isVisible;
520
this.languageModelsService.updateModelPickerPreference(model.model.identifier, newVisibility);
521
const metadata = this.languageModelsService.lookupLanguageModel(model.model.identifier);
522
const index = this.viewModelEntries.indexOf(model);
523
if (metadata && index !== -1) {
524
model.id = this.getModelId(model.model);
525
model.model.metadata = metadata;
526
if (this.groupBy === ChatModelGroup.Visibility) {
527
this.modelsSorted = false;
528
}
529
this.splice(index, 1, [model]);
530
}
531
}
532
533
private getModelId(modelEntry: ILanguageModel): string {
534
return `${modelEntry.provider.group.name}.${modelEntry.identifier}.${modelEntry.metadata.version}-visible:${modelEntry.metadata.isUserSelectable}`;
535
}
536
537
private getProviderGroupId(group: ILanguageModelsProviderGroup): string {
538
return `${group.vendor}-${group.name}`;
539
}
540
541
toggleCollapsed(viewModelEntry: IViewModelEntry): void {
542
const id = isLanguageModelGroupEntry(viewModelEntry) ? viewModelEntry.id : isLanguageModelProviderEntry(viewModelEntry) ? viewModelEntry.id : undefined;
543
if (!id) {
544
return;
545
}
546
this.selectedEntry = viewModelEntry;
547
if (!this.collapsedGroups.delete(id)) {
548
this.collapsedGroups.add(id);
549
}
550
this.doFilter();
551
}
552
553
collapseAll(): void {
554
this.collapsedGroups.clear();
555
for (const entry of this.viewModelEntries) {
556
if (isLanguageModelProviderEntry(entry) || isLanguageModelGroupEntry(entry)) {
557
this.collapsedGroups.add(entry.id);
558
}
559
}
560
this.filter(this.searchValue);
561
}
562
563
getConfiguredVendors(): ILanguageModelProvider[] {
564
const result: ILanguageModelProvider[] = [];
565
const seenVendors = new Set<string>();
566
for (const modelEntry of this.languageModels) {
567
if (!seenVendors.has(modelEntry.provider.group.name)) {
568
seenVendors.add(modelEntry.provider.group.name);
569
result.push(modelEntry.provider);
570
}
571
}
572
return result;
573
}
574
}
575
576
class ModelItemMatches {
577
578
readonly modelNameMatches: IMatch[] | null = null;
579
readonly modelIdMatches: IMatch[] | null = null;
580
readonly providerMatches: IMatch[] | null = null;
581
readonly capabilityMatches: IMatch[] | null = null;
582
583
constructor(modelEntry: ILanguageModel, searchValue: string, words: string[], completeMatch: boolean) {
584
if (!completeMatch) {
585
// Match against model name
586
this.modelNameMatches = modelEntry.metadata.name ?
587
this.matches(searchValue, modelEntry.metadata.name, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words) :
588
null;
589
590
this.modelIdMatches = this.matches(searchValue, modelEntry.metadata.id, or(matchesWords, matchesCamelCase), words);
591
592
// Match against vendor display name
593
this.providerMatches = this.matches(searchValue, modelEntry.provider.group.name, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words);
594
595
// Match against capabilities
596
if (modelEntry.metadata.capabilities) {
597
const capabilityStrings: string[] = [];
598
if (modelEntry.metadata.capabilities.toolCalling) {
599
capabilityStrings.push('tools', 'toolCalling');
600
}
601
if (modelEntry.metadata.capabilities.vision) {
602
capabilityStrings.push('vision');
603
}
604
if (modelEntry.metadata.capabilities.agentMode) {
605
capabilityStrings.push('agent', 'agentMode');
606
}
607
if (modelEntry.metadata.capabilities.editTools) {
608
capabilityStrings.push(...modelEntry.metadata.capabilities.editTools);
609
}
610
611
const capabilityString = capabilityStrings.join(' ');
612
if (capabilityString) {
613
this.capabilityMatches = this.matches(searchValue, capabilityString, or(matchesWords, matchesCamelCase), words);
614
}
615
}
616
}
617
}
618
619
private matches(searchValue: string | null, wordToMatchAgainst: string, wordMatchesFilter: IFilter, words: string[]): IMatch[] | null {
620
let matches = searchValue ? wordFilter(searchValue, wordToMatchAgainst) : null;
621
if (!matches) {
622
matches = this.matchesWords(words, wordToMatchAgainst, wordMatchesFilter);
623
}
624
if (matches) {
625
matches = this.filterAndSort(matches);
626
}
627
return matches;
628
}
629
630
private matchesWords(words: string[], wordToMatchAgainst: string, wordMatchesFilter: IFilter): IMatch[] | null {
631
let matches: IMatch[] | null = [];
632
for (const word of words) {
633
const wordMatches = wordMatchesFilter(word, wordToMatchAgainst);
634
if (wordMatches) {
635
matches = [...(matches || []), ...wordMatches];
636
} else {
637
matches = null;
638
break;
639
}
640
}
641
return matches;
642
}
643
644
private filterAndSort(matches: IMatch[]): IMatch[] {
645
return distinct(matches, (a => a.start + '.' + a.end))
646
.filter(match => !matches.some(m => !(m.start === match.start && m.end === match.end) && (m.start <= match.start && m.end >= match.end)))
647
.sort((a, b) => a.start - b.start);
648
}
649
}
650
651