improvements to models management editor (#278653)

This commit is contained in:
Sandeep Somavarapu
2025-11-20 22:47:25 +01:00
committed by GitHub
parent 33ea003f03
commit 1c7035b4f4
3 changed files with 186 additions and 163 deletions

View File

@@ -16,6 +16,7 @@ export const VENDOR_ENTRY_TEMPLATE_ID = 'vendor.entry.template';
const wordFilter = or(matchesBaseContiguousSubString, matchesWords);
const CAPABILITY_REGEX = /@capability:\s*([^\s]+)/gi;
const VISIBLE_REGEX = /@visible:\s*(true|false)/i;
const PROVIDER_REGEX = /@provider:\s*((".+?")|([^\s]+))/gi;
export const SEARCH_SUGGESTIONS = {
FILTER_TYPES: [
@@ -54,6 +55,7 @@ export interface IModelItemEntry {
templateId: string;
providerMatches?: IMatch[];
modelNameMatches?: IMatch[];
modelIdMatches?: IMatch[];
capabilityMatches?: string[];
}
@@ -111,93 +113,155 @@ export class ChatModelsViewModel extends EditorModel {
filter(searchValue: string): readonly IViewModelEntry[] {
this.searchValue = searchValue;
let modelEntries = this.modelEntries;
const capabilityMatchesMap = new Map<string, string[]>();
const visibleMatches = VISIBLE_REGEX.exec(searchValue);
if (visibleMatches && visibleMatches[1]) {
const visible = visibleMatches[1].toLowerCase() === 'true';
modelEntries = this.filterByVisible(modelEntries, visible);
searchValue = searchValue.replace(VISIBLE_REGEX, '');
}
const providerNames: string[] = [];
let match: RegExpExecArray | null;
const providerRegexGlobal = /@provider:\s*((".+?")|([^\s]+))/gi;
while ((match = providerRegexGlobal.exec(searchValue)) !== null) {
const providerName = match[2] ? match[2].substring(1, match[2].length - 1) : match[3];
providerNames.push(providerName);
}
// Apply provider filter with OR logic if multiple providers
if (providerNames.length > 0) {
modelEntries = this.filterByProviders(modelEntries, providerNames);
searchValue = searchValue.replace(/@provider:\s*((".+?")|([^\s]+))/gi, '').replace(/@vendor:\s*((".+?")|([^\s]+))/gi, '');
}
// Apply capability filters with AND logic if multiple capabilities
const capabilityNames: string[] = [];
let capabilityMatch: RegExpExecArray | null;
while ((capabilityMatch = CAPABILITY_REGEX.exec(searchValue)) !== null) {
capabilityNames.push(capabilityMatch[1].toLowerCase());
}
if (capabilityNames.length > 0) {
const filteredEntries = this.filterByCapabilities(modelEntries, capabilityNames);
modelEntries = [];
for (const { entry, matchedCapabilities } of filteredEntries) {
modelEntries.push(entry);
capabilityMatchesMap.set(ChatModelsViewModel.getId(entry), matchedCapabilities);
}
searchValue = searchValue.replace(/@capability:\s*([^\s]+)/gi, '');
}
searchValue = searchValue.trim();
const filtered = searchValue ? this.filterByText(modelEntries, searchValue, capabilityMatchesMap) : this.toEntries(modelEntries, capabilityMatchesMap);
const filtered = this.filterModels(this.modelEntries, searchValue);
this.splice(0, this._viewModelEntries.length, filtered);
return this.viewModelEntries;
}
private filterByProviders(modelEntries: IModelEntry[], providers: string[]): IModelEntry[] {
const lowerProviders = providers.map(p => p.toLowerCase().trim());
return modelEntries.filter(m =>
lowerProviders.some(provider =>
m.vendor.toLowerCase() === provider ||
m.vendorDisplayName.toLowerCase() === provider
)
);
private filterModels(modelEntries: IModelEntry[], searchValue: string): (IVendorItemEntry | IModelItemEntry)[] {
let visible: boolean | undefined;
const visibleMatches = VISIBLE_REGEX.exec(searchValue);
if (visibleMatches && visibleMatches[1]) {
visible = visibleMatches[1].toLowerCase() === 'true';
searchValue = searchValue.replace(VISIBLE_REGEX, '');
}
private filterByVisible(modelEntries: IModelEntry[], visible: boolean): IModelEntry[] {
return modelEntries.filter(m => (m.metadata.isUserSelectable ?? false) === visible);
const providerNames: string[] = [];
let providerMatch: RegExpExecArray | null;
PROVIDER_REGEX.lastIndex = 0;
while ((providerMatch = PROVIDER_REGEX.exec(searchValue)) !== null) {
const providerName = providerMatch[2] ? providerMatch[2].substring(1, providerMatch[2].length - 1) : providerMatch[3];
providerNames.push(providerName);
}
if (providerNames.length > 0) {
searchValue = searchValue.replace(PROVIDER_REGEX, '');
}
private filterByCapabilities(modelEntries: IModelEntry[], capabilities: string[]): { entry: IModelEntry; matchedCapabilities: string[] }[] {
const result: { entry: IModelEntry; matchedCapabilities: string[] }[] = [];
for (const m of modelEntries) {
if (!m.metadata.capabilities) {
const capabilities: string[] = [];
let capabilityMatch: RegExpExecArray | null;
CAPABILITY_REGEX.lastIndex = 0;
while ((capabilityMatch = CAPABILITY_REGEX.exec(searchValue)) !== null) {
capabilities.push(capabilityMatch[1].toLowerCase());
}
if (capabilities.length > 0) {
searchValue = searchValue.replace(CAPABILITY_REGEX, '');
}
const quoteAtFirstChar = searchValue.charAt(0) === '"';
const quoteAtLastChar = searchValue.charAt(searchValue.length - 1) === '"';
const completeMatch = quoteAtFirstChar && quoteAtLastChar;
if (quoteAtFirstChar) {
searchValue = searchValue.substring(1);
}
if (quoteAtLastChar) {
searchValue = searchValue.substring(0, searchValue.length - 1);
}
searchValue = searchValue.trim();
const isFiltering = searchValue !== '' || capabilities.length > 0 || providerNames.length > 0 || visible !== undefined;
const result: (IVendorItemEntry | IModelItemEntry)[] = [];
const words = searchValue.split(' ');
const allVendors = new Set(this.modelEntries.map(m => m.vendor));
const showHeaders = allVendors.size > 1;
const addedVendors = new Set<string>();
const lowerProviders = providerNames.map(p => p.toLowerCase().trim());
for (const modelEntry of modelEntries) {
if (!isFiltering && showHeaders && this.collapsedVendors.has(modelEntry.vendor)) {
if (!addedVendors.has(modelEntry.vendor)) {
const vendorInfo = this.languageModelsService.getVendors().find(v => v.vendor === modelEntry.vendor);
result.push({
type: 'vendor',
id: `vendor-${modelEntry.vendor}`,
vendorEntry: {
vendor: modelEntry.vendor,
vendorDisplayName: modelEntry.vendorDisplayName,
managementCommand: vendorInfo?.managementCommand
},
templateId: VENDOR_ENTRY_TEMPLATE_ID,
collapsed: true
});
addedVendors.add(modelEntry.vendor);
}
continue;
}
const allMatchedCapabilities: string[] = [];
let matchesAll = true;
if (visible !== undefined) {
if ((modelEntry.metadata.isUserSelectable ?? false) !== visible) {
continue;
}
}
if (lowerProviders.length > 0) {
const matchesProvider = lowerProviders.some(provider =>
modelEntry.vendor.toLowerCase() === provider ||
modelEntry.vendorDisplayName.toLowerCase() === provider
);
if (!matchesProvider) {
continue;
}
}
// Filter by capabilities
let matchedCapabilities: string[] = [];
if (capabilities.length > 0) {
if (!modelEntry.metadata.capabilities) {
continue;
}
let matchesAll = true;
for (const capability of capabilities) {
const matchedForThisCapability = this.getMatchingCapabilities(m, capability);
const matchedForThisCapability = this.getMatchingCapabilities(modelEntry, capability);
if (matchedForThisCapability.length === 0) {
matchesAll = false;
break;
}
allMatchedCapabilities.push(...matchedForThisCapability);
matchedCapabilities.push(...matchedForThisCapability);
}
if (!matchesAll) {
continue;
}
matchedCapabilities = distinct(matchedCapabilities);
}
if (matchesAll) {
result.push({ entry: m, matchedCapabilities: distinct(allMatchedCapabilities) });
// Filter by text
let modelMatches: ModelItemMatches | undefined;
if (searchValue) {
modelMatches = new ModelItemMatches(modelEntry, searchValue, words, completeMatch);
if (!modelMatches.modelNameMatches && !modelMatches.modelIdMatches && !modelMatches.providerMatches && !modelMatches.capabilityMatches) {
continue;
}
}
if (showHeaders && !addedVendors.has(modelEntry.vendor)) {
const vendorInfo = this.languageModelsService.getVendors().find(v => v.vendor === modelEntry.vendor);
result.push({
type: 'vendor',
id: `vendor-${modelEntry.vendor}`,
vendorEntry: {
vendor: modelEntry.vendor,
vendorDisplayName: modelEntry.vendorDisplayName,
managementCommand: vendorInfo?.managementCommand
},
templateId: VENDOR_ENTRY_TEMPLATE_ID,
collapsed: false
});
addedVendors.add(modelEntry.vendor);
}
const modelId = ChatModelsViewModel.getId(modelEntry);
result.push({
type: 'model',
id: modelId,
templateId: MODEL_ENTRY_TEMPLATE_ID,
modelEntry,
modelNameMatches: modelMatches?.modelNameMatches || undefined,
modelIdMatches: modelMatches?.modelIdMatches || undefined,
providerMatches: modelMatches?.providerMatches || undefined,
capabilityMatches: matchedCapabilities.length ? matchedCapabilities : undefined,
});
}
return result;
}
@@ -239,42 +303,6 @@ export class ChatModelsViewModel extends EditorModel {
return matchedCapabilities;
}
private filterByText(modelEntries: IModelEntry[], searchValue: string, capabilityMatchesMap: Map<string, string[]>): IModelItemEntry[] {
const quoteAtFirstChar = searchValue.charAt(0) === '"';
const quoteAtLastChar = searchValue.charAt(searchValue.length - 1) === '"';
const completeMatch = quoteAtFirstChar && quoteAtLastChar;
if (quoteAtFirstChar) {
searchValue = searchValue.substring(1);
}
if (quoteAtLastChar) {
searchValue = searchValue.substring(0, searchValue.length - 1);
}
searchValue = searchValue.trim();
const result: IModelItemEntry[] = [];
const words = searchValue.split(' ');
for (const modelEntry of modelEntries) {
const modelMatches = new ModelItemMatches(modelEntry, searchValue, words, completeMatch);
if (modelMatches.modelNameMatches
|| modelMatches.providerMatches
|| modelMatches.capabilityMatches
) {
const modelId = ChatModelsViewModel.getId(modelEntry);
result.push({
type: 'model',
id: modelId,
templateId: MODEL_ENTRY_TEMPLATE_ID,
modelEntry,
modelNameMatches: modelMatches.modelNameMatches || undefined,
providerMatches: modelMatches.providerMatches || undefined,
capabilityMatches: capabilityMatchesMap.get(modelId),
});
}
}
return result;
}
getVendors(): IUserFriendlyLanguageModel[] {
return [...this.languageModelsService.getVendors()].sort((a, b) => {
if (a.vendor === 'copilot') { return -1; }
@@ -342,55 +370,20 @@ export class ChatModelsViewModel extends EditorModel {
this.filter(this.searchValue);
}
getConfiguredVendors(): IVendorItemEntry[] {
return this.toEntries(this.modelEntries, new Map(), true) as IVendorItemEntry[];
}
private toEntries(modelEntries: IModelEntry[], capabilityMatchesMap: Map<string, string[]>, excludeModels?: boolean): (IVendorItemEntry | IModelItemEntry)[] {
const result: (IVendorItemEntry | IModelItemEntry)[] = [];
const vendorMap = new Map<string, IModelEntry[]>();
for (const modelEntry of modelEntries) {
const models = vendorMap.get(modelEntry.vendor) || [];
models.push(modelEntry);
vendorMap.set(modelEntry.vendor, models);
}
const showVendorHeaders = vendorMap.size > 1;
for (const [vendor, models] of vendorMap) {
const firstModel = models[0];
const isCollapsed = this.collapsedVendors.has(vendor);
const vendorInfo = this.languageModelsService.getVendors().find(v => v.vendor === vendor);
if (showVendorHeaders) {
getConfiguredVendors(): IVendorEntry[] {
const result: IVendorEntry[] = [];
const seenVendors = new Set<string>();
for (const modelEntry of this.modelEntries) {
if (!seenVendors.has(modelEntry.vendor)) {
seenVendors.add(modelEntry.vendor);
const vendorInfo = this.languageModelsService.getVendors().find(v => v.vendor === modelEntry.vendor);
result.push({
type: 'vendor',
id: `vendor-${vendor}`,
vendorEntry: {
vendor: firstModel.vendor,
vendorDisplayName: firstModel.vendorDisplayName,
vendor: modelEntry.vendor,
vendorDisplayName: modelEntry.vendorDisplayName,
managementCommand: vendorInfo?.managementCommand
},
templateId: VENDOR_ENTRY_TEMPLATE_ID,
collapsed: isCollapsed
});
}
if (!excludeModels && (!isCollapsed || !showVendorHeaders)) {
for (const modelEntry of models) {
const modelId = ChatModelsViewModel.getId(modelEntry);
result.push({
type: 'model',
id: modelId,
modelEntry,
templateId: MODEL_ENTRY_TEMPLATE_ID,
capabilityMatches: capabilityMatchesMap.get(modelId),
});
}
}
}
return result;
}
}
@@ -398,6 +391,7 @@ export class ChatModelsViewModel extends EditorModel {
class ModelItemMatches {
readonly modelNameMatches: IMatch[] | null = null;
readonly modelIdMatches: IMatch[] | null = null;
readonly providerMatches: IMatch[] | null = null;
readonly capabilityMatches: IMatch[] | null = null;
@@ -408,10 +402,7 @@ class ModelItemMatches {
this.matches(searchValue, modelEntry.metadata.name, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words) :
null;
// Match against model identifier
if (!this.modelNameMatches) {
this.modelNameMatches = this.matches(searchValue, modelEntry.identifier, or(matchesWords, matchesCamelCase), words);
}
this.modelIdMatches = this.matches(searchValue, modelEntry.identifier, or(matchesWords, matchesCamelCase), words);
// Match against vendor display name
this.providerMatches = this.matches(searchValue, modelEntry.vendorDisplayName, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words);

View File

@@ -226,7 +226,7 @@ class ModelsSearchFilterDropdownMenuActionViewItem extends DropdownMenuActionVie
const configuredVendors = this.viewModel.getConfiguredVendors();
if (configuredVendors.length > 1) {
actions.push(new Separator());
actions.push(...configuredVendors.map(vendor => this.createProviderAction(vendor.vendorEntry.vendor, vendor.vendorEntry.vendorDisplayName)));
actions.push(...configuredVendors.map(vendor => this.createProviderAction(vendor.vendor, vendor.vendorDisplayName)));
}
return actions;
@@ -717,17 +717,25 @@ export class ChatModelsWidget extends Disposable {
{
triggerCharacters: ['@', ':'],
provideResults: (query: string) => {
const providerSuggestions = this.viewModel.getVendors().map(v => `@provider:"${v.displayName}"`);
const allSuggestions = [
...providerSuggestions,
...SEARCH_SUGGESTIONS.CAPABILITIES,
...SEARCH_SUGGESTIONS.VISIBILITY,
];
if (!query.trim()) {
return allSuggestions;
}
const queryParts = query.split(/\s/g);
const lastPart = queryParts[queryParts.length - 1];
if (lastPart.startsWith('@provider:')) {
const vendors = this.viewModel.getVendors();
return vendors.map(v => `@provider:"${v.displayName}"`);
return providerSuggestions;
} else if (lastPart.startsWith('@capability:')) {
return SEARCH_SUGGESTIONS.CAPABILITIES;
} else if (lastPart.startsWith('@visible:')) {
return SEARCH_SUGGESTIONS.VISIBILITY;
} else if (lastPart.startsWith('@')) {
return SEARCH_SUGGESTIONS.FILTER_TYPES;
return allSuggestions;
}
return [];
}
@@ -930,7 +938,7 @@ export class ChatModelsWidget extends Disposable {
}
const vendors = this.viewModel.getVendors();
const configuredVendors = new Set(this.viewModel.getConfiguredVendors().map(cv => cv.vendorEntry.vendor));
const configuredVendors = new Set(this.viewModel.getConfiguredVendors().map(cv => cv.vendor));
const vendorsWithoutModels = vendors.filter(v => !configuredVendors.has(v.vendor));
const hasPlan = this.chatEntitlementService.entitlement !== ChatEntitlement.Unknown && this.chatEntitlementService.entitlement !== ChatEntitlement.Available;

View File

@@ -383,6 +383,15 @@ suite('ChatModelsViewModel', () => {
assert.ok(models[0].modelNameMatches);
});
test('should filter by text matching model id', () => {
const results = viewModel.filter('copilot-gpt-4o');
const models = results.filter(r => !isVendorEntry(r)) as IModelItemEntry[];
assert.strictEqual(models.length, 1);
assert.strictEqual(models[0].modelEntry.identifier, 'copilot-gpt-4o');
assert.ok(models[0].modelIdMatches);
});
test('should filter by text matching vendor name', () => {
const results = viewModel.filter('GitHub');
@@ -731,4 +740,19 @@ suite('ChatModelsViewModel', () => {
assert.strictEqual(vendors[0].vendorEntry.vendor, 'copilot');
}
});
test('should show vendor headers when filtered', () => {
const results = viewModel.filter('GPT');
const vendors = results.filter(isVendorEntry);
assert.ok(vendors.length > 0);
});
test('should not show vendor headers when filtered if only one vendor exists', async () => {
const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(store, chatEntitlementService);
await singleVendorViewModel.resolve();
const results = singleVendorViewModel.filter('GPT');
const vendors = results.filter(isVendorEntry);
assert.strictEqual(vendors.length, 0);
});
});