diff --git a/src/vs/base/common/jsonSchema.ts b/src/vs/base/common/jsonSchema.ts index ac98a0ddd5f15..3a674ccdd0f0b 100644 --- a/src/vs/base/common/jsonSchema.ts +++ b/src/vs/base/common/jsonSchema.ts @@ -87,6 +87,7 @@ export interface IJSONSchema { suggestSortText?: string; allowComments?: boolean; allowTrailingCommas?: boolean; + secret?: boolean; } export interface IJSONSchemaMap { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index d488e98f9305e..94592df5f3782 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -63,7 +63,7 @@ import { ChatResponseClearToPreviousToolInvocationReason, IChatContentInlineRefe import { IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { IChatRequestVariableValue } from '../../contrib/chat/common/attachments/chatVariables.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IChatMessage, IChatResponsePart, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; +import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; import { IPreparedToolInvocation, IToolInvocation, IToolInvocationPreparationContext, IToolProgressStep, IToolResult, ToolDataSource } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IPromptFileContext, IPromptFileResource } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugTestRunReference, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from '../../contrib/debug/common/debug.js'; @@ -1341,7 +1341,7 @@ export interface MainThreadLanguageModelsShape extends IDisposable { } export interface ExtHostLanguageModelsShape { - $provideLanguageModelChatInfo(vendor: string, options: { silent: boolean }, token: CancellationToken): Promise; + $provideLanguageModelChatInfo(vendor: string, options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; $updateModelAccesslist(data: { from: ExtensionIdentifier; to: ExtensionIdentifier; enabled: boolean }[]): void; $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier, messages: SerializableObjectWithBuffers, options: { [name: string]: any }, token: CancellationToken): Promise; $acceptResponsePart(requestId: number, chunk: SerializableObjectWithBuffers): Promise; diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index 9323770f23c52..dc5cc3d8bcac0 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -17,7 +17,7 @@ import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionIdentifierSet, IE import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { Progress } from '../../../platform/progress/common/progress.js'; -import { IChatMessage, IChatResponsePart, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../contrib/chat/common/languageModels.js'; +import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../contrib/chat/common/languageModels.js'; import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../contrib/chat/common/widget/input/modelPickerWidget.js'; import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -171,12 +171,12 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { }); } - async $provideLanguageModelChatInfo(vendor: string, options: { silent: boolean }, token: CancellationToken): Promise { + async $provideLanguageModelChatInfo(vendor: string, options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise { const data = this._languageModelProviders.get(vendor); if (!data) { return []; } - const modelInformation: vscode.LanguageModelChatInformation[] = await data.provider.provideLanguageModelChatInformation(options, token) ?? []; + const modelInformation: vscode.LanguageModelChatInformation[] = await data.provider.provideLanguageModelChatInformation({ silent: options.silent, configuration: options.configuration }, token) ?? []; const modelMetadataAndIdentifier: ILanguageModelChatMetadataAndIdentifier[] = modelInformation.map((m): ILanguageModelChatMetadataAndIdentifier => { let auth; if (m.requiresAuthorization && isProposedApiEnabled(data.extension, 'chatProvider')) { @@ -213,7 +213,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { agentMode: !!m.capabilities.toolCalling } : undefined, }, - identifier: `${vendor}/${m.id}`, + identifier: options.group ? `${vendor}/${options.group}/${m.id}` : `${vendor}/${m.id}`, }; }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 2543324c7e775..22ca202b3feea 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -205,7 +205,7 @@ abstract class OpenChatGlobalAction extends Action2 { } if (opts?.modelSelector) { - const ids = await languageModelService.selectLanguageModels(opts.modelSelector, false); + const ids = await languageModelService.selectLanguageModels(opts.modelSelector); const id = ids.sort().at(0); if (!id) { throw new Error(`No language models found matching selector: ${JSON.stringify(opts.modelSelector)}.`); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatLanguageModelActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatLanguageModelActions.ts index 4a2e78a9df976..ae11a7c88a204 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatLanguageModelActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatLanguageModelActions.ts @@ -18,6 +18,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; class ManageLanguageModelAuthenticationAction extends Action2 { static readonly ID = 'workbench.action.chat.manageLanguageModelAuthentication'; @@ -227,6 +228,27 @@ class ManageLanguageModelAuthenticationAction extends Action2 { } } +class ConfigureLanguageModelsGroupAction extends Action2 { + constructor() { + super({ + id: 'lm.addLanguageModelsProviderGroup', + title: localize('lm.configureGroup', 'Add Language Models Group'), + }); + } + + async run(accessor: ServicesAccessor, languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { + const languageModelsService = accessor.get(ILanguageModelsService); + + if (!languageModelsProviderGroup) { + throw new Error('Language model group is required'); + } + + const { name, vendor, ...configuration } = languageModelsProviderGroup; + await languageModelsService.addLanguageModelsProviderGroup(name, vendor, configuration); + } +} + export function registerLanguageModelActions() { registerAction2(ManageLanguageModelAuthenticationAction); + registerAction2(ConfigureLanguageModelsGroupAction); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index fbc042bbd3541..de827f77c3d73 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -104,6 +104,7 @@ import { SimpleBrowserOverlay } from './attachments/simpleBrowserEditorOverlay.j import { ChatEditor, IChatEditorOptions } from './widgetHosts/editor/chatEditor.js'; import { ChatEditorInput, ChatEditorInputSerializer } from './widgetHosts/editor/chatEditorInput.js'; import { ChatLayoutService } from './widget/chatLayoutService.js'; +import { ChatLanguageModelsDataContribution, LanguageModelsConfigurationService } from './languageModelsConfigurationService.js'; import './chatManagement/chatManagement.contribution.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; @@ -131,6 +132,7 @@ import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; import { ConfigureToolSets, UserToolSetsContributions } from './tools/toolSetsContribution.js'; import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler.js'; import { ChatWidgetService } from './widget/chatWidgetService.js'; +import { ILanguageModelsConfigurationService } from '../common/languageModelsConfiguration.js'; import { ChatWindowNotifier } from './chatWindowNotifier.js'; const toolReferenceNameEnumValues: string[] = []; @@ -1167,6 +1169,7 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, LanguageModelToolsExtensionPointHandler, WorkbenchPhase.BlockRestore); @@ -1229,6 +1232,7 @@ registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delay registerSingleton(IQuickChatService, QuickChatService, InstantiationType.Delayed); registerSingleton(IChatAccessibilityService, ChatAccessibilityService, InstantiationType.Delayed); registerSingleton(IChatWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); +registerSingleton(ILanguageModelsConfigurationService, LanguageModelsConfigurationService, InstantiationType.Delayed); registerSingleton(ILanguageModelsService, LanguageModelsService, InstantiationType.Delayed); registerSingleton(ILanguageModelStatsService, LanguageModelStatsService, InstantiationType.Delayed); registerSingleton(IChatSlashCommandService, ChatSlashCommandService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts index 5a0e46d7e9330..a377d9a5b1730 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts @@ -3,13 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { distinct, coalesce } from '../../../../../base/common/arrays.js'; +import { distinct } from '../../../../../base/common/arrays.js'; import { IMatch, IFilter, or, matchesCamelCase, matchesWords, matchesBaseContiguousSubString } from '../../../../../base/common/filters.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { ILanguageModelsService, ILanguageModelChatMetadata, IUserFriendlyLanguageModel } from '../../../chat/common/languageModels.js'; +import { ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelChatMetadataAndIdentifier } from '../../../chat/common/languageModels.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { localize } from '../../../../../nls.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ILanguageModelsProviderGroup, ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; +import { Throttler } from '../../../../../base/common/async.js'; +import Severity from '../../../../../base/common/severity.js'; export const MODEL_ENTRY_TEMPLATE_ID = 'model.entry.template'; export const VENDOR_ENTRY_TEMPLATE_ID = 'vendor.entry.template'; @@ -37,56 +40,69 @@ export const SEARCH_SUGGESTIONS = { ] }; -export interface IVendorEntry { - vendor: string; - vendorDisplayName: string; - managementCommand?: string; +export interface ILanguageModelProvider { + vendor: IUserFriendlyLanguageModel; + group: ILanguageModelsProviderGroup; } -export interface IModelEntry { - vendor: string; - vendorDisplayName: string; - identifier: string; - metadata: ILanguageModelChatMetadata; +export interface ILanguageModel extends ILanguageModelChatMetadataAndIdentifier { + provider: ILanguageModelProvider; } -export interface IModelItemEntry { +export interface ILanguageModelEntry { type: 'model'; id: string; - modelEntry: IModelEntry; templateId: string; + model: ILanguageModel; providerMatches?: IMatch[]; modelNameMatches?: IMatch[]; modelIdMatches?: IMatch[]; capabilityMatches?: string[]; } -export interface IVendorItemEntry { - type: 'vendor'; +export interface ILanguageModelGroupEntry { + type: 'group'; id: string; - vendorEntry: IVendorEntry; - templateId: string; + label: string; collapsed: boolean; + templateId: string; } -export interface IGroupItemEntry { - type: 'group'; +export interface ILanguageModelProviderEntry { + type: 'vendor'; id: string; - group: string; label: string; templateId: string; collapsed: boolean; + vendorEntry: ILanguageModelProvider; } -export function isVendorEntry(entry: IViewModelEntry): entry is IVendorItemEntry { +export interface IStatusEntry { + type: 'status'; + id: string; + message: string; + severity: Severity; +} + +export interface ILanguageModelEntriesGroup { + group: ILanguageModelGroupEntry | ILanguageModelProviderEntry; + models: ILanguageModel[]; + status?: IStatusEntry; +} + +export function isLanguageModelProviderEntry(entry: IViewModelEntry): entry is ILanguageModelProviderEntry { return entry.type === 'vendor'; } -export function isGroupEntry(entry: IViewModelEntry): entry is IGroupItemEntry { +export function isLanguageModelGroupEntry(entry: IViewModelEntry): entry is ILanguageModelGroupEntry { return entry.type === 'group'; } -export type IViewModelEntry = IModelItemEntry | IVendorItemEntry | IGroupItemEntry; +export function isStatusEntry(entry: IViewModelEntry): entry is IStatusEntry { + return entry.type === 'status'; +} + +export type IViewModelEntry = ILanguageModelEntry | ILanguageModelProviderEntry | ILanguageModelGroupEntry | IStatusEntry; export interface IViewModelChangeEvent { at: number; @@ -107,7 +123,10 @@ export class ChatModelsViewModel extends Disposable { private readonly _onDidChangeGrouping = this._register(new Emitter()); readonly onDidChangeGrouping = this._onDidChangeGrouping.event; - private modelEntries: IModelEntry[]; + private languageModels: ILanguageModel[]; + private languageModelGroupStatuses: Array<{ provider: ILanguageModelProvider; status: { severity: Severity; message: string } }> = []; + private languageModelGroups: ILanguageModelEntriesGroup[] = []; + private readonly collapsedGroups = new Set(); private searchValue: string = ''; private modelsSorted: boolean = false; @@ -118,19 +137,23 @@ export class ChatModelsViewModel extends Disposable { if (this._groupBy !== groupBy) { this._groupBy = groupBy; this.collapsedGroups.clear(); - this.modelEntries = this.sortModels(this.modelEntries); - this.filter(this.searchValue); + this.languageModelGroups = this.groupModels(this.languageModels); + this.doFilter(); this._onDidChangeGrouping.fire(groupBy); } } + private readonly refreshThrottler = this._register(new Throttler()); + constructor( @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILanguageModelsConfigurationService private readonly languageModelsConfigurationService: ILanguageModelsConfigurationService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService ) { super(); - this.modelEntries = []; + this.languageModels = []; this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.refresh())); + this._register(this.languageModelsConfigurationService.onDidChangeLanguageModelGroups(() => this.refresh())); } private readonly _viewModelEntries: IViewModelEntry[] = []; @@ -152,16 +175,50 @@ export class ChatModelsViewModel extends Disposable { } filter(searchValue: string): readonly IViewModelEntry[] { + if (searchValue !== this.searchValue) { + this.collapsedGroups.clear(); + } this.searchValue = searchValue; if (!this.modelsSorted) { - this.modelEntries = this.sortModels(this.modelEntries); + this.languageModelGroups = this.groupModels(this.languageModels); } - const filtered = this.filterModels(this.modelEntries, searchValue); - this.splice(0, this._viewModelEntries.length, filtered); + + this.doFilter(); return this.viewModelEntries; } - private filterModels(modelEntries: IModelEntry[], searchValue: string): IViewModelEntry[] { + private doFilter(): void { + const viewModelEntries: IViewModelEntry[] = []; + const shouldShowGroupHeaders = this.languageModelGroups.length > 1; + + for (const group of this.languageModelGroups) { + if (this.collapsedGroups.has(group.group.id)) { + group.group.collapsed = true; + if (shouldShowGroupHeaders) { + viewModelEntries.push(group.group); + } + continue; + } + + const groupEntries: IViewModelEntry[] = []; + if (group.status) { + groupEntries.push(group.status); + } + + groupEntries.push(...this.filterModels(group.models, this.searchValue)); + + if (groupEntries.length > 0) { + group.group.collapsed = false; + if (shouldShowGroupHeaders) { + viewModelEntries.push(group.group); + } + viewModelEntries.push(...groupEntries); + } + } + this.splice(0, this._viewModelEntries.length, viewModelEntries); + } + + private filterModels(modelEntries: ILanguageModel[], searchValue: string): IViewModelEntry[] { let visible: boolean | undefined; const visibleMatches = VISIBLE_REGEX.exec(searchValue); @@ -202,13 +259,8 @@ export class ChatModelsViewModel extends Disposable { } searchValue = searchValue.trim(); - const isFiltering = searchValue !== '' || capabilities.length > 0 || providerNames.length > 0 || visible !== undefined; - const result: IViewModelEntry[] = []; const words = searchValue.split(' '); - const allVendors = new Set(this.modelEntries.map(m => m.vendor)); - const showHeaders = allVendors.size > 1; - const addedGroups = new Set(); const lowerProviders = providerNames.map(p => p.toLowerCase().trim()); for (const modelEntry of modelEntries) { @@ -220,8 +272,8 @@ export class ChatModelsViewModel extends Disposable { if (lowerProviders.length > 0) { const matchesProvider = lowerProviders.some(provider => - modelEntry.vendor.toLowerCase() === provider || - modelEntry.vendorDisplayName.toLowerCase() === provider + modelEntry.provider.vendor.vendor.toLowerCase() === provider || + modelEntry.provider.vendor.displayName.toLowerCase() === provider ); if (!matchesProvider) { continue; @@ -258,56 +310,12 @@ export class ChatModelsViewModel extends Disposable { } } - if (this.groupBy === ChatModelGroup.Vendor) { - if (showHeaders) { - if (!addedGroups.has(modelEntry.vendor)) { - const isCollapsed = !isFiltering && this.collapsedGroups.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: isCollapsed - }); - addedGroups.add(modelEntry.vendor); - } - - if (!isFiltering && this.collapsedGroups.has(modelEntry.vendor)) { - continue; - } - } - } else if (this.groupBy === ChatModelGroup.Visibility) { - const isVisible = modelEntry.metadata.isUserSelectable ?? false; - const groupKey = isVisible ? 'visible' : 'hidden'; - if (!addedGroups.has(groupKey)) { - const isCollapsed = !isFiltering && this.collapsedGroups.has(groupKey); - result.push({ - type: 'group', - id: `group-${groupKey}`, - group: groupKey, - label: isVisible ? localize('visible', "Visible") : localize('hidden', "Hidden"), - templateId: GROUP_ENTRY_TEMPLATE_ID, - collapsed: isCollapsed - }); - addedGroups.add(groupKey); - } - - if (!isFiltering && this.collapsedGroups.has(groupKey)) { - continue; - } - } - - const modelId = ChatModelsViewModel.getId(modelEntry); + const modelId = this.getModelId(modelEntry); result.push({ type: 'model', id: modelId, templateId: MODEL_ENTRY_TEMPLATE_ID, - modelEntry, + model: modelEntry, modelNameMatches: modelMatches?.modelNameMatches || undefined, modelIdMatches: modelMatches?.modelIdMatches || undefined, providerMatches: modelMatches?.providerMatches || undefined, @@ -317,7 +325,7 @@ export class ChatModelsViewModel extends Disposable { return result; } - private getMatchingCapabilities(modelEntry: IModelEntry, capability: string): string[] { + private getMatchingCapabilities(modelEntry: ILanguageModel, capability: string): string[] { const matchedCapabilities: string[] = []; if (!modelEntry.metadata.capabilities) { return matchedCapabilities; @@ -355,33 +363,103 @@ export class ChatModelsViewModel extends Disposable { return matchedCapabilities; } - private sortModels(modelEntries: IModelEntry[]): IModelEntry[] { + private groupModels(languageModels: ILanguageModel[]): ILanguageModelEntriesGroup[] { + const result: ILanguageModelEntriesGroup[] = []; if (this.groupBy === ChatModelGroup.Visibility) { - modelEntries.sort((a, b) => { - const aVisible = a.metadata.isUserSelectable ?? false; - const bVisible = b.metadata.isUserSelectable ?? false; - if (aVisible === bVisible) { - if (a.vendor === b.vendor) { - return a.metadata.name.localeCompare(b.metadata.name); - } - if (a.vendor === 'copilot') { return -1; } - if (b.vendor === 'copilot') { return 1; } - return a.vendorDisplayName.localeCompare(b.vendorDisplayName); + const visible = [], hidden = []; + for (const model of languageModels) { + if (model.metadata.isUserSelectable) { + visible.push(model); + } else { + hidden.push(model); + } + } + result.push({ + group: { + type: 'group', + id: 'visible', + label: localize('visible', "Visible"), + templateId: GROUP_ENTRY_TEMPLATE_ID, + collapsed: this.collapsedGroups.has('visible') + }, + models: visible + }); + result.push({ + group: { + type: 'group', + id: 'hidden', + label: localize('hidden', "Hidden"), + templateId: GROUP_ENTRY_TEMPLATE_ID, + collapsed: this.collapsedGroups.has('hidden'), + }, + models: hidden + }); + } + else if (this.groupBy === ChatModelGroup.Vendor) { + for (const model of languageModels) { + const groupId = this.getProviderGroupId(model.provider.group); + let group = result.find(group => group.group.id === groupId); + if (!group) { + group = { + group: this.createLanguageModelProviderEntry(model.provider), + models: [], + }; + result.push(group); + } + group.models.push(model); + } + for (const statusGroup of this.languageModelGroupStatuses) { + const groupId = this.getProviderGroupId(statusGroup.provider.group); + let group = result.find(group => group.group.id === groupId); + if (!group) { + group = { + group: this.createLanguageModelProviderEntry(statusGroup.provider), + models: [], + }; + result.push(group); } - return aVisible ? -1 : 1; + group.status = { + id: `status.${group.group.id}`, + type: 'status', + ...statusGroup.status, + }; + } + result.sort((a, b) => { + if (a.models[0]?.provider.vendor.vendor === 'copilot') { return -1; } + if (b.models[0]?.provider.vendor.vendor === 'copilot') { return 1; } + return a.group.label.localeCompare(b.group.label); }); - } else if (this.groupBy === ChatModelGroup.Vendor) { - modelEntries.sort((a, b) => { - if (a.vendor === b.vendor) { + } + for (const group of result) { + group.models.sort((a, b) => { + if (a.provider.vendor.vendor === 'copilot' && b.provider.vendor.vendor === 'copilot') { + return a.metadata.name.localeCompare(b.metadata.name); + } + if (a.provider.vendor.vendor === 'copilot') { return -1; } + if (b.provider.vendor.vendor === 'copilot') { return 1; } + if (a.provider.group.name === b.provider.group.name) { return a.metadata.name.localeCompare(b.metadata.name); } - if (a.vendor === 'copilot') { return -1; } - if (b.vendor === 'copilot') { return 1; } - return a.vendorDisplayName.localeCompare(b.vendorDisplayName); + return a.provider.group.name.localeCompare(b.provider.group.name); }); } this.modelsSorted = true; - return modelEntries; + return result; + } + + private createLanguageModelProviderEntry(provider: ILanguageModelProvider): ILanguageModelProviderEntry { + const id = this.getProviderGroupId(provider.group); + return { + type: 'vendor', + id, + label: provider.group.name, + templateId: VENDOR_ENTRY_TEMPLATE_ID, + collapsed: this.collapsedGroups.has(id), + vendorEntry: { + group: provider.group, + vendor: provider.vendor + }, + }; } getVendors(): IUserFriendlyLanguageModel[] { @@ -392,52 +470,59 @@ export class ChatModelsViewModel extends Disposable { }); } - async refresh(): Promise { - this.modelEntries = []; + refresh(): Promise { + return this.refreshThrottler.queue(() => this.doRefresh()); + } + + private async doRefresh(): Promise { + this.languageModels = []; + this.languageModelGroupStatuses = []; for (const vendor of this.getVendors()) { - const modelIdentifiers = await this.languageModelsService.selectLanguageModels({ vendor: vendor.vendor }, vendor.vendor === 'copilot'); - const models = coalesce(modelIdentifiers.map(identifier => { - const metadata = this.languageModelsService.lookupLanguageModel(identifier); - if (!metadata) { - return undefined; - } - if (vendor.vendor === 'copilot' && metadata.id === 'auto') { - return undefined; - } - return { - vendor: vendor.vendor, - vendorDisplayName: vendor.displayName, - identifier, - metadata + const models: ILanguageModel[] = []; + const languageModelsGroups = await this.languageModelsService.fetchLanguageModelGroups(vendor.vendor); + for (const group of languageModelsGroups) { + const provider: ILanguageModelProvider = { + group: group.group ?? { + vendor: vendor.vendor, + name: vendor.displayName + }, + vendor }; - })); - - this.modelEntries.push(...models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name))); - const modelEntries = distinct(this.modelEntries, modelEntry => ChatModelsViewModel.getId(modelEntry)); - - if (this._groupBy === ChatModelGroup.Visibility) { - this.modelEntries = this.sortModels(modelEntries); - } else { - this.modelEntries = modelEntries; - if (models.every(m => !m.metadata.isUserSelectable)) { - this.collapsedGroups.add(vendor.vendor); + if (group.status) { + this.languageModelGroupStatuses.push({ + provider, + status: { + message: group.status.message, + severity: group.status.severity + } + }); + } + for (const model of group.models) { + if (vendor.vendor === 'copilot' && model.metadata.id === 'auto') { + continue; + } + models.push({ + identifier: model.identifier, + metadata: model.metadata, + provider, + }); } } - - this.modelEntries = this._groupBy === ChatModelGroup.Visibility ? this.sortModels(modelEntries) : modelEntries; - this.filter(this.searchValue); + this.languageModels.push(...models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name))); + this.languageModelGroups = this.groupModels(this.languageModels); + this.doFilter(); } } - toggleVisibility(model: IModelItemEntry): void { - const isVisible = model.modelEntry.metadata.isUserSelectable ?? false; + toggleVisibility(model: ILanguageModelEntry): void { + const isVisible = model.model.metadata.isUserSelectable ?? false; const newVisibility = !isVisible; - this.languageModelsService.updateModelPickerPreference(model.modelEntry.identifier, newVisibility); - const metadata = this.languageModelsService.lookupLanguageModel(model.modelEntry.identifier); + this.languageModelsService.updateModelPickerPreference(model.model.identifier, newVisibility); + const metadata = this.languageModelsService.lookupLanguageModel(model.model.identifier); const index = this.viewModelEntries.indexOf(model); if (metadata && index !== -1) { - model.id = ChatModelsViewModel.getId(model.modelEntry); - model.modelEntry.metadata = metadata; + model.id = this.getModelId(model.model); + model.model.metadata = metadata; if (this.groupBy === ChatModelGroup.Visibility) { this.modelsSorted = false; } @@ -445,12 +530,16 @@ export class ChatModelsViewModel extends Disposable { } } - private static getId(modelEntry: IModelEntry): string { - return `${modelEntry.identifier}.${modelEntry.metadata.version}-visible:${modelEntry.metadata.isUserSelectable}`; + private getModelId(modelEntry: ILanguageModel): string { + return `${modelEntry.provider.group.name}.${modelEntry.identifier}.${modelEntry.metadata.version}-visible:${modelEntry.metadata.isUserSelectable}`; + } + + private getProviderGroupId(group: ILanguageModelsProviderGroup): string { + return `${group.vendor}-${group.name}`; } toggleCollapsed(viewModelEntry: IViewModelEntry): void { - const id = isGroupEntry(viewModelEntry) ? viewModelEntry.group : isVendorEntry(viewModelEntry) ? viewModelEntry.vendorEntry.vendor : undefined; + const id = isLanguageModelGroupEntry(viewModelEntry) ? viewModelEntry.id : isLanguageModelProviderEntry(viewModelEntry) ? viewModelEntry.id : undefined; if (!id) { return; } @@ -458,36 +547,26 @@ export class ChatModelsViewModel extends Disposable { if (!this.collapsedGroups.delete(id)) { this.collapsedGroups.add(id); } - this.filter(this.searchValue); + this.doFilter(); } collapseAll(): void { - const allGroupIds = new Set(); + this.collapsedGroups.clear(); for (const entry of this.viewModelEntries) { - if (isVendorEntry(entry)) { - allGroupIds.add(entry.vendorEntry.vendor); - } else if (isGroupEntry(entry)) { - allGroupIds.add(entry.group); + if (isLanguageModelProviderEntry(entry) || isLanguageModelGroupEntry(entry)) { + this.collapsedGroups.add(entry.id); } } - for (const id of allGroupIds) { - this.collapsedGroups.add(id); - } this.filter(this.searchValue); } - getConfiguredVendors(): IVendorEntry[] { - const result: IVendorEntry[] = []; + getConfiguredVendors(): ILanguageModelProvider[] { + const result: ILanguageModelProvider[] = []; const seenVendors = new Set(); - 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({ - vendor: modelEntry.vendor, - vendorDisplayName: modelEntry.vendorDisplayName, - managementCommand: vendorInfo?.managementCommand - }); + for (const modelEntry of this.languageModels) { + if (!seenVendors.has(modelEntry.provider.group.name)) { + seenVendors.add(modelEntry.provider.group.name); + result.push(modelEntry.provider); } } return result; @@ -501,17 +580,17 @@ class ModelItemMatches { readonly providerMatches: IMatch[] | null = null; readonly capabilityMatches: IMatch[] | null = null; - constructor(modelEntry: IModelEntry, searchValue: string, words: string[], completeMatch: boolean) { + constructor(modelEntry: ILanguageModel, searchValue: string, words: string[], completeMatch: boolean) { if (!completeMatch) { // Match against model name this.modelNameMatches = modelEntry.metadata.name ? this.matches(searchValue, modelEntry.metadata.name, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words) : null; - this.modelIdMatches = this.matches(searchValue, modelEntry.identifier, or(matchesWords, matchesCamelCase), words); + this.modelIdMatches = this.matches(searchValue, modelEntry.metadata.id, or(matchesWords, matchesCamelCase), words); // Match against vendor display name - this.providerMatches = this.matches(searchValue, modelEntry.vendorDisplayName, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words); + this.providerMatches = this.matches(searchValue, modelEntry.provider.group.name, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words); // Match against capabilities if (modelEntry.metadata.capabilities) { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 72df9f4cfd226..9d874651c3afd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -9,7 +9,8 @@ import { Emitter } from '../../../../../base/common/event.js'; import * as DOM from '../../../../../base/browser/dom.js'; import { Button, IButtonOptions } from '../../../../../base/browser/ui/button/button.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { ILanguageModelsService } from '../../../chat/common/languageModels.js'; +import { ILanguageModelsService, IUserFriendlyLanguageModel } from '../../../chat/common/languageModels.js'; +import { ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; import { localize } from '../../../../../nls.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -22,7 +23,7 @@ import { IContextMenuService } from '../../../../../platform/contextview/browser import { IAction, toAction, Action, Separator, SubmenuAction } from '../../../../../base/common/actions.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { ChatModelsViewModel, IModelEntry, IModelItemEntry, IVendorItemEntry, IGroupItemEntry, SEARCH_SUGGESTIONS, isVendorEntry, isGroupEntry, ChatModelGroup } from './chatModelsViewModel.js'; +import { ChatModelsViewModel, ILanguageModel, ILanguageModelEntry, ILanguageModelProviderEntry, ILanguageModelGroupEntry, SEARCH_SUGGESTIONS, isLanguageModelProviderEntry, isLanguageModelGroupEntry, ChatModelGroup, IViewModelEntry, isStatusEntry, IStatusEntry } from './chatModelsViewModel.js'; import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; import { SuggestEnabledInput } from '../../../codeEditor/browser/suggestEnabledInput/suggestEnabledInput.js'; import { Delayer } from '../../../../../base/common/async.js'; @@ -37,6 +38,8 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IEditorProgressService } from '../../../../../platform/progress/common/progress.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { CONTEXT_MODELS_SEARCH_FOCUS } from '../../common/constants.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import Severity from '../../../../../base/common/severity.js'; const $ = DOM.$; @@ -44,9 +47,7 @@ const HEADER_HEIGHT = 30; const VENDOR_ROW_HEIGHT = 30; const MODEL_ROW_HEIGHT = 26; -type TableEntry = IModelItemEntry | IVendorItemEntry | IGroupItemEntry; - -export function getModelHoverContent(model: IModelEntry): MarkdownString { +export function getModelHoverContent(model: ILanguageModel): MarkdownString { const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); markdown.appendMarkdown(`**${model.metadata.name}**`); if (model.metadata.id !== model.metadata.version) { @@ -240,7 +241,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.vendor, vendor.vendorDisplayName))); + actions.push(...configuredVendors.map(vendor => this.createProviderAction(vendor.vendor.vendor, vendor.group.name))); } // Group By @@ -254,10 +255,10 @@ class ModelsSearchFilterDropdownMenuActionViewItem extends DropdownMenuActionVie } } -class Delegate implements ITableVirtualDelegate { +class Delegate implements ITableVirtualDelegate { readonly headerRowHeight = HEADER_HEIGHT; - getHeight(element: TableEntry): number { - return isVendorEntry(element) || isGroupEntry(element) ? VENDOR_ROW_HEIGHT : MODEL_ROW_HEIGHT; + getHeight(element: IViewModelEntry): number { + return isLanguageModelProviderEntry(element) || isLanguageModelGroupEntry(element) ? VENDOR_ROW_HEIGHT : MODEL_ROW_HEIGHT; } } @@ -267,30 +268,36 @@ interface IModelTableColumnTemplateData { readonly elementDisposables: DisposableStore; } -abstract class ModelsTableColumnRenderer implements ITableRenderer { +abstract class ModelsTableColumnRenderer implements ITableRenderer { abstract readonly templateId: string; abstract renderTemplate(container: HTMLElement): T; - renderElement(element: TableEntry, index: number, templateData: T): void { + renderElement(element: IViewModelEntry, index: number, templateData: T): void { templateData.elementDisposables.clear(); - const isVendor = isVendorEntry(element); - const isGroup = isGroupEntry(element); + const isVendor = isLanguageModelProviderEntry(element); + const isGroup = isLanguageModelGroupEntry(element); + const isStatus = isStatusEntry(element); templateData.container.classList.add('models-table-column'); templateData.container.parentElement!.classList.toggle('models-vendor-row', isVendor || isGroup); templateData.container.parentElement!.classList.toggle('models-model-row', !isVendor && !isGroup); - templateData.container.parentElement!.classList.toggle('model-hidden', !isVendor && !isGroup && !element.modelEntry.metadata.isUserSelectable); + templateData.container.parentElement!.classList.toggle('models-status-row', isStatus); + templateData.container.parentElement!.classList.toggle('model-hidden', !isVendor && !isGroup && !isStatus && !element.model.metadata.isUserSelectable); if (isVendor) { this.renderVendorElement(element, index, templateData); } else if (isGroup) { this.renderGroupElement(element, index, templateData); + } else if (isStatus) { + this.renderStatusElement(element, index, templateData); } else { this.renderModelElement(element, index, templateData); } } - abstract renderVendorElement(element: IVendorItemEntry, index: number, templateData: T): void; - abstract renderGroupElement(element: IGroupItemEntry, index: number, templateData: T): void; - abstract renderModelElement(element: IModelItemEntry, index: number, templateData: T): void; + abstract renderVendorElement(element: ILanguageModelProviderEntry, index: number, templateData: T): void; + abstract renderGroupElement(element: ILanguageModelGroupEntry, index: number, templateData: T): void; + abstract renderModelElement(element: ILanguageModelEntry, index: number, templateData: T): void; + + protected renderStatusElement(element: IStatusEntry, index: number, templateData: T): void { } disposeTemplate(templateData: T): void { templateData.elementDisposables.dispose(); @@ -330,20 +337,20 @@ class GutterColumnRenderer extends ModelsTableColumnRenderer _${entry.modelEntry.metadata.id}@${entry.modelEntry.metadata.version}_ `); + markdown.appendMarkdown(`**${entry.model.metadata.name}**`); + if (entry.model.metadata.id !== entry.model.metadata.version) { + markdown.appendMarkdown(`  _${entry.model.metadata.id}@${entry.model.metadata.version}_ `); } else { - markdown.appendMarkdown(`  _${entry.modelEntry.metadata.id}_ `); + markdown.appendMarkdown(`  _${entry.model.metadata.id}_ `); } markdown.appendText(`\n`); - if (entry.modelEntry.metadata.statusIcon && entry.modelEntry.metadata.tooltip) { - if (entry.modelEntry.metadata.statusIcon) { - markdown.appendMarkdown(`$(${entry.modelEntry.metadata.statusIcon.id}) `); + if (entry.model.metadata.statusIcon && entry.model.metadata.tooltip) { + if (entry.model.metadata.statusIcon) { + markdown.appendMarkdown(`$(${entry.model.metadata.statusIcon.id}) `); } - markdown.appendMarkdown(`${entry.modelEntry.metadata.tooltip}`); + markdown.appendMarkdown(`${entry.model.metadata.tooltip}`); markdown.appendText(`\n`); } - if (!entry.modelEntry.metadata.isUserSelectable) { + if (!entry.model.metadata.isUserSelectable) { markdown.appendMarkdown(`\n\n${localize('models.userSelectable', 'This model is hidden in the chat model picker')}`); } @@ -465,6 +473,26 @@ class ModelNameColumnRenderer extends ModelsTableColumnRenderer { @@ -674,7 +702,12 @@ class ActionsColumnRenderer extends ModelsTableColumnRenderer AnchorAlignment.RIGHT + } + )); return { container, actionBar, @@ -693,33 +735,71 @@ class ActionsColumnRenderer extends ModelsTableColumnRenderer this.languageModelsService.configureLanguageModelsProviderGroup(vendorEntry.vendor.vendor, vendorEntry.group.name) + })); + secondaryActions.push(toAction({ + id: 'deleteAction', + label: localize('models.deleteAction', 'Delete'), + class: ThemeIcon.asClassName(Codicon.trash), + run: async () => { + const result = await this.dialogService.confirm({ + type: 'info', + message: localize('models.deleteConfirmation', "Would you like to delete {0}?", vendorEntry.group.name) + }); + if (!result.confirmed) { + return; + } + await this.languageModelsConfigurationService.removeLanguageModelsProviderGroup(vendorEntry.group); + } + })); + } else if (vendorEntry.vendor.managementCommand) { + primaryActions.push(toAction({ id: 'manageVendor', - label: localize('models.manageProvider', 'Manage {0}...', entry.vendorEntry.vendorDisplayName), + label: localize('models.manageProvider', 'Manage {0}...', vendorEntry.group.name), class: ThemeIcon.asClassName(Codicon.gear), run: async () => { - await this.commandService.executeCommand(vendorEntry.managementCommand!, vendorEntry.vendor); + await this.commandService.executeCommand(vendorEntry.vendor.managementCommand!, vendorEntry.vendor.vendor); this.viewModel.refresh(); } - - }); - templateData.actionBar.push(action, { icon: true, label: false }); + })); } + templateData.actionBar.setActions(primaryActions, secondaryActions); } - override renderGroupElement(entry: IGroupItemEntry, index: number, templateData: IActionsColumnTemplateData): void { + override renderGroupElement(entry: ILanguageModelGroupEntry, index: number, templateData: IActionsColumnTemplateData): void { } - override renderModelElement(entry: IModelItemEntry, index: number, templateData: IActionsColumnTemplateData): void { - // Visibility action moved to name column + override renderModelElement(entry: ILanguageModelEntry, index: number, templateData: IActionsColumnTemplateData): void { + const { model } = entry; + + if (!model.provider.vendor.configuration) { + return; + } + + const primaryActions: IAction[] = []; + const secondaryActions: IAction[] = []; + + secondaryActions.push(toAction({ + id: 'configureConfiguration', + class: ThemeIcon.asClassName(Codicon.edit), + label: localize('models.configure', 'Configure...'), + run: () => this.languageModelsService.configureLanguageModelsProviderGroup(model.provider.vendor.vendor, model.provider.group.name) + })); + + templateData.actionBar.setActions(primaryActions, secondaryActions); } } @@ -744,16 +824,16 @@ class ProviderColumnRenderer extends ModelsTableColumnRenderer; + private table!: WorkbenchTable; private tableContainer!: HTMLElement; private addButtonContainer!: HTMLElement; private addButton!: Button; @@ -875,8 +955,8 @@ export class ChatModelsWidget extends Disposable { this.viewModel.collapseAll(); } )); - collapseAllAction.enabled = this.viewModel.viewModelEntries.some(e => isVendorEntry(e) || isGroupEntry(e)); - this._register(this.viewModel.onDidChange(() => collapseAllAction.enabled = this.viewModel.viewModelEntries.some(e => isVendorEntry(e) || isGroupEntry(e)))); + collapseAllAction.enabled = this.viewModel.viewModelEntries.some(e => isLanguageModelGroupEntry(e) || isLanguageModelProviderEntry(e)); + this._register(this.viewModel.onDidChange(() => collapseAllAction.enabled = this.viewModel.viewModelEntries.some(e => isLanguageModelProviderEntry(e) || isLanguageModelGroupEntry(e)))); this._register(this.searchWidget.onInputDidChange(() => { clearSearchAction.enabled = !!this.searchWidget.getValue(); @@ -953,7 +1033,7 @@ export class ChatModelsWidget extends Disposable { minimumWidth: 40, maximumWidth: 40, templateId: GutterColumnRenderer.TEMPLATE_ID, - project(row: TableEntry): TableEntry { return row; } + project(row: IViewModelEntry): IViewModelEntry { return row; } }, { label: localize('modelName', 'Name'), @@ -961,7 +1041,7 @@ export class ChatModelsWidget extends Disposable { weight: 0.35, minimumWidth: 200, templateId: ModelNameColumnRenderer.TEMPLATE_ID, - project(row: TableEntry): TableEntry { return row; } + project(row: IViewModelEntry): IViewModelEntry { return row; } } ]; @@ -972,7 +1052,7 @@ export class ChatModelsWidget extends Disposable { weight: 0.15, minimumWidth: 100, templateId: ProviderColumnRenderer.TEMPLATE_ID, - project(row: TableEntry): TableEntry { return row; } + project(row: IViewModelEntry): IViewModelEntry { return row; } }); } @@ -983,7 +1063,7 @@ export class ChatModelsWidget extends Disposable { weight: 0.1, minimumWidth: 140, templateId: TokenLimitsColumnRenderer.TEMPLATE_ID, - project(row: TableEntry): TableEntry { return row; } + project(row: IViewModelEntry): IViewModelEntry { return row; } }, { label: localize('capabilities', 'Capabilities'), @@ -991,7 +1071,7 @@ export class ChatModelsWidget extends Disposable { weight: 0.25, minimumWidth: 180, templateId: CapabilitiesColumnRenderer.TEMPLATE_ID, - project(row: TableEntry): TableEntry { return row; } + project(row: IViewModelEntry): IViewModelEntry { return row; } }, { label: localize('cost', 'Multiplier'), @@ -999,7 +1079,7 @@ export class ChatModelsWidget extends Disposable { weight: 0.05, minimumWidth: 60, templateId: MultiplierColumnRenderer.TEMPLATE_ID, - project(row: TableEntry): TableEntry { return row; } + project(row: IViewModelEntry): IViewModelEntry { return row; } }, { label: '', @@ -1008,7 +1088,7 @@ export class ChatModelsWidget extends Disposable { minimumWidth: 64, maximumWidth: 64, templateId: ActionsColumnRenderer.TEMPLATE_ID, - project(row: TableEntry): TableEntry { return row; } + project(row: IViewModelEntry): IViewModelEntry { return row; } } ); @@ -1028,28 +1108,30 @@ export class ChatModelsWidget extends Disposable { providerColumnRenderer ], { - identityProvider: { getId: (e: TableEntry) => e.id }, + identityProvider: { getId: (e: IViewModelEntry) => e.id }, horizontalScrolling: false, accessibilityProvider: { - getAriaLabel: (e: TableEntry) => { - if (isVendorEntry(e)) { - return localize('vendor.ariaLabel', '{0} Models', e.vendorEntry.vendorDisplayName); - } else if (isGroupEntry(e)) { + getAriaLabel: (e: IViewModelEntry) => { + if (isLanguageModelProviderEntry(e)) { + return localize('vendor.ariaLabel', '{0} Models', e.vendorEntry.group.name); + } else if (isLanguageModelGroupEntry(e)) { return e.id === 'visible' ? localize('visible.ariaLabel', 'Visible Models') : localize('hidden.ariaLabel', 'Hidden Models'); + } else if (isStatusEntry(e)) { + return localize('status.ariaLabel', 'Status: {0}', e.message); } const ariaLabels = []; - ariaLabels.push(localize('model.name', '{0} from {1}', e.modelEntry.metadata.name, e.modelEntry.vendorDisplayName)); - if (e.modelEntry.metadata.maxInputTokens && e.modelEntry.metadata.maxOutputTokens) { - ariaLabels.push(localize('model.contextSize', 'Context size: {0} input tokens and {1} output tokens', formatTokenCount(e.modelEntry.metadata.maxInputTokens), formatTokenCount(e.modelEntry.metadata.maxOutputTokens))); + ariaLabels.push(localize('model.name', '{0} from {1}', e.model.metadata.name, e.model.provider.vendor.displayName)); + if (e.model.metadata.maxInputTokens && e.model.metadata.maxOutputTokens) { + ariaLabels.push(localize('model.contextSize', 'Context size: {0} input tokens and {1} output tokens', formatTokenCount(e.model.metadata.maxInputTokens), formatTokenCount(e.model.metadata.maxOutputTokens))); } - if (e.modelEntry.metadata.capabilities) { - ariaLabels.push(localize('model.capabilities', 'Capabilities: {0}', Object.keys(e.modelEntry.metadata.capabilities).join(', '))); + if (e.model.metadata.capabilities) { + ariaLabels.push(localize('model.capabilities', 'Capabilities: {0}', Object.keys(e.model.metadata.capabilities).join(', '))); } - const multiplierText = (e.modelEntry.metadata.detail && e.modelEntry.metadata.detail.trim().toLowerCase() !== e.modelEntry.vendor.trim().toLowerCase()) ? e.modelEntry.metadata.detail : '-'; + const multiplierText = (e.model.metadata.detail && e.model.metadata.detail.trim().toLowerCase() !== e.model.provider.vendor.vendor.trim().toLowerCase()) ? e.model.metadata.detail : '-'; if (multiplierText !== '-') { ariaLabels.push(localize('multiplier.tooltip', "Every chat message counts {0} towards your premium model request quota", multiplierText)); } - if (e.modelEntry.metadata.isUserSelectable) { + if (e.model.metadata.isUserSelectable) { ariaLabels.push(localize('model.visible', 'This model is visible in the chat model picker')); } else { ariaLabels.push(localize('model.hidden', 'This model is hidden in the chat model picker')); @@ -1063,20 +1145,20 @@ export class ChatModelsWidget extends Disposable { openOnSingleClick: true, alwaysConsumeMouseWheel: false, } - )) as WorkbenchTable; + )) as WorkbenchTable; this.tableDisposables.add(this.table.onContextMenu(e => { if (!e.element) { return; } const entry = e.element; - if (isVendorEntry(entry) && entry.vendorEntry.managementCommand) { + if (isLanguageModelProviderEntry(entry) && entry.vendorEntry.vendor.managementCommand) { const actions: IAction[] = [ toAction({ id: 'manageVendor', - label: localize('models.manageProvider', 'Manage {0}...', entry.vendorEntry.vendorDisplayName), + label: localize('models.manageProvider', 'Manage {0}...', entry.vendorEntry.group.name), run: async () => { - await this.commandService.executeCommand(entry.vendorEntry.managementCommand!, entry.vendorEntry.vendor); + await this.commandService.executeCommand(entry.vendorEntry.vendor.managementCommand!, entry.vendorEntry.vendor); await this.viewModel.refresh(); } }) @@ -1097,18 +1179,16 @@ export class ChatModelsWidget extends Disposable { this.table.setSelection([selectedEntryIndex]); } - const vendors = this.viewModel.getVendors(); - const configuredVendors = new Set(this.viewModel.getConfiguredVendors().map(cv => cv.vendor)); - const vendorsWithoutModels = vendors.filter(v => !configuredVendors.has(v.vendor)); + const configurableVendors = this.languageModelsService.getVendors().filter(vendor => vendor.managementCommand || vendor.configuration); const hasPlan = this.chatEntitlementService.entitlement !== ChatEntitlement.Unknown && this.chatEntitlementService.entitlement !== ChatEntitlement.Available; - this.addButton.enabled = hasPlan && vendorsWithoutModels.length > 0; + this.addButton.enabled = hasPlan && configurableVendors.length > 0; - this.dropdownActions = vendorsWithoutModels.map(vendor => toAction({ + this.dropdownActions = configurableVendors.map(vendor => toAction({ id: `enable-${vendor.vendor}`, label: vendor.displayName, run: async () => { - await this.enableProvider(vendor.vendor); + await this.addModelsForVendor(vendor); } })); })); @@ -1117,7 +1197,10 @@ export class ChatModelsWidget extends Disposable { if (!element) { return; } - if (isVendorEntry(element) || isGroupEntry(element)) { + if (isStatusEntry(element)) { + return; + } + if (isLanguageModelProviderEntry(element) || isLanguageModelGroupEntry(element)) { this.viewModel.toggleCollapsed(element); } else if (!DOM.isMouseEvent(browserEvent) || browserEvent.detail === 2) { this.viewModel.toggleVisibility(element); @@ -1141,9 +1224,8 @@ export class ChatModelsWidget extends Disposable { }); } - private async enableProvider(vendorId: string): Promise { - await this.languageModelsService.selectLanguageModels({ vendor: vendorId }, true); - await this.viewModel.refresh(); + private async addModelsForVendor(vendor: IUserFriendlyLanguageModel): Promise { + this.languageModelsService.configureLanguageModelsProviderGroup(vendor.vendor); } public layout(height: number, width: number): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css index d34c36964ce7a..eedca42731d13 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css @@ -121,16 +121,34 @@ flex: 0 1 auto; } +.models-widget .models-table-container .monaco-table-tr.models-status-row .monaco-table-td .model-name-container .model-name .monaco-highlighted-label.error-status { + color: var(--vscode-errorForeground); +} + +.models-widget .models-table-container .monaco-table-tr.models-status-row .monaco-table-td .model-name-container .model-name .monaco-highlighted-label.warning-status { + color: var(--vscode-editorWarning-foreground); +} + /** Actions column styling **/ -.models-widget .models-table-container .monaco-table-td .actions-column { - display: flex; +.models-widget .models-table-container .monaco-table-tr.models-model-row.model-hidden .models-table-column.models-actions-column { + opacity: 1; +} + +.models-widget .models-table-container .monaco-list-row .monaco-table-tr .models-table-column.models-actions-column .actions-container { + display: none; align-items: center; justify-content: center; width: 100%; } -.models-widget .models-table-container .monaco-table-td .actions-column .monaco-action-bar { +.models-widget .models-table-container .monaco-list-row.focused .monaco-table-tr .models-table-column.models-actions-column .actions-container, +.models-widget .models-table-container .monaco-list-row.selected .monaco-table-tr .models-table-column.models-actions-column .actions-container, +.models-widget .models-table-container .monaco-list-row:hover .monaco-table-tr .models-table-column.models-actions-column .actions-container { + display: flex; +} + +.models-widget .models-table-container .monaco-table-td .models-actions-column .actions-container .monaco-action-bar { margin-right: 9px; } diff --git a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts new file mode 100644 index 0000000000000..e36741305ccc4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts @@ -0,0 +1,322 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Mutable } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { ITextEditorService } from '../../../services/textfile/common/textEditorService.js'; +import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; +import { equals } from '../../../../base/common/objects.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { JSONVisitor, visit } from '../../../../base/common/json.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; +import { getCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { ILanguageModelsConfigurationService, ILanguageModelsProviderGroup } from '../common/languageModelsConfiguration.js'; +import { IJSONContributionRegistry, Extensions as JSONExtensions } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { ILanguageModelsService } from '../common/languageModels.js'; +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; + +type LanguageModelsProviderGroups = Mutable[]; + +export class LanguageModelsConfigurationService extends Disposable implements ILanguageModelsConfigurationService { + + declare _serviceBrand: undefined; + + private readonly modelsConfigurationFile: URI; + + private readonly _onDidChangeLanguageModelGroups = new Emitter(); + readonly onDidChangeLanguageModelGroups: Event = this._onDidChangeLanguageModelGroups.event; + + private languageModelsProviderGroups: LanguageModelsProviderGroups = []; + + constructor( + @IFileService private readonly fileService: IFileService, + @ITextFileService private readonly textFileService: ITextFileService, + @ITextModelService private readonly textModelService: ITextModelService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @ITextEditorService private readonly textEditorService: ITextEditorService, + @IUserDataProfileService userDataProfileService: IUserDataProfileService, + @IUriIdentityService uriIdentityService: IUriIdentityService, + ) { + super(); + this.modelsConfigurationFile = uriIdentityService.extUri.joinPath(userDataProfileService.currentProfile.location, 'models.json'); + this.updateLanguageModelsConfiguration(); + this._register(fileService.watch(this.modelsConfigurationFile)); + this._register(fileService.onDidFilesChange(e => { + if (e.contains(this.modelsConfigurationFile)) { + this.updateLanguageModelsConfiguration(); + } + })); + } + + private setLanguageModelsConfiguration(languageModelsConfiguration: LanguageModelsProviderGroups): void { + if (equals(this.languageModelsProviderGroups, languageModelsConfiguration)) { + return; + } + this.languageModelsProviderGroups = languageModelsConfiguration; + this._onDidChangeLanguageModelGroups.fire(); + } + + private async updateLanguageModelsConfiguration(): Promise { + const languageModelsProviderGroups = await this.withLanguageModelsProviderGroups(); + this.setLanguageModelsConfiguration(languageModelsProviderGroups); + } + + getLanguageModelsProviderGroups(): readonly ILanguageModelsProviderGroup[] { + return this.languageModelsProviderGroups; + } + + async addLanguageModelsProviderGroup(toAdd: ILanguageModelsProviderGroup): Promise { + await this.withLanguageModelsProviderGroups(async languageModelsProviderGroups => { + if (languageModelsProviderGroups.some(({ name, vendor }) => name === toAdd.name && vendor === toAdd.vendor)) { + throw new Error(`Language model group with name ${toAdd.name} already exists for vendor ${toAdd.vendor}`); + } + languageModelsProviderGroups.push(toAdd); + return languageModelsProviderGroups; + }); + + await this.updateLanguageModelsConfiguration(); + const result = this.getLanguageModelsProviderGroups().find(group => group.name === toAdd.name && group.vendor === toAdd.vendor); + if (!result) { + throw new Error(`Language model group with name ${toAdd.name} not found for vendor ${toAdd.vendor}`); + } + return result; + } + + async updateLanguageModelsProviderGroup(toUpdate: ILanguageModelsProviderGroup): Promise { + await this.withLanguageModelsProviderGroups(async languageModelsProviderGroups => { + const result: LanguageModelsProviderGroups = []; + for (const group of languageModelsProviderGroups) { + if (group.name === toUpdate.name && group.vendor === toUpdate.vendor) { + result.push(toUpdate); + } else { + result.push(group); + } + } + return result; + }); + + await this.updateLanguageModelsConfiguration(); + const result = this.getLanguageModelsProviderGroups().find(group => group.name === toUpdate.name && group.vendor === toUpdate.vendor); + if (!result) { + throw new Error(`Language model group with name ${toUpdate.name} not found for vendor ${toUpdate.vendor}`); + } + return result; + } + + async removeLanguageModelsProviderGroup(toRemove: ILanguageModelsProviderGroup): Promise { + await this.withLanguageModelsProviderGroups(async languageModelsProviderGroups => { + const result: LanguageModelsProviderGroups = []; + for (const group of languageModelsProviderGroups) { + if (group.name === toRemove.name && group.vendor === toRemove.vendor) { + continue; + } + result.push(group); + } + return result; + }); + await this.updateLanguageModelsConfiguration(); + } + + async configureLanguageModels(range?: IRange): Promise { + const editor = await this.editorGroupsService.activeGroup.openEditor(this.textEditorService.createTextEditor({ resource: this.modelsConfigurationFile })); + if (!editor || !range) { + return; + } + + const codeEditor = getCodeEditor(editor.getControl()); + if (!codeEditor) { + return; + } + + const position = { lineNumber: range.startLineNumber, column: range.startColumn }; + codeEditor.setPosition(position); + codeEditor.revealPositionNearTop(position); + codeEditor.focus(); + } + + private async withLanguageModelsProviderGroups(update?: (languageModelsProviderGroups: LanguageModelsProviderGroups) => Promise): Promise { + const exists = await this.fileService.exists(this.modelsConfigurationFile); + if (!exists) { + await this.fileService.writeFile(this.modelsConfigurationFile, VSBuffer.fromString(JSON.stringify([], undefined, '\t'))); + } + const ref = await this.textModelService.createModelReference(this.modelsConfigurationFile); + const model = ref.object.textEditorModel; + try { + const languageModelsProviderGroups = parseLanguageModelsProviderGroups(model); + if (!update) { + return languageModelsProviderGroups; + } + const updatedLanguageModelsProviderGroups = await update(languageModelsProviderGroups); + for (const group of updatedLanguageModelsProviderGroups) { + delete group.range; + } + model.setValue(JSON.stringify(updatedLanguageModelsProviderGroups, undefined, '\t')); + await this.textFileService.save(this.modelsConfigurationFile); + return updatedLanguageModelsProviderGroups; + } finally { + ref.dispose(); + } + } +} + +export function parseLanguageModelsProviderGroups(model: ITextModel): LanguageModelsProviderGroups { + const configuration: LanguageModelsProviderGroups = []; + let currentProperty: string | null = null; + let currentParent: unknown = configuration; + const previousParents: unknown[] = []; + + function onValue(value: unknown, offset: number, length: number) { + if (Array.isArray(currentParent)) { + (currentParent as unknown[]).push(value); + } else if (currentProperty !== null) { + (currentParent as Record)[currentProperty] = value; + if (currentProperty === 'configuration') { + const start = model.getPositionAt(offset); + const range: Mutable = { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: start.lineNumber, + endColumn: start.column + }; + if (value && typeof value === 'object') { + (value as { _parentConfigurationRange?: Mutable })._parentConfigurationRange = range; + } else { + const end = model.getPositionAt(offset + length); + range.endLineNumber = end.lineNumber; + range.endColumn = end.column; + } + (currentParent as { configurationRange?: IRange }).configurationRange = range; + } + } + } + + const visitor: JSONVisitor = { + onObjectBegin: (offset: number, length: number) => { + const object: Record & { range?: IRange } = {}; + if (Array.isArray(currentParent)) { + const start = model.getPositionAt(offset); + const end = model.getPositionAt(offset + length); + object.range = { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column + }; + } + onValue(object, offset, length); + previousParents.push(currentParent); + currentParent = object; + currentProperty = null; + }, + onObjectProperty: (name: string, offset: number, length: number) => { + currentProperty = name; + }, + onObjectEnd: (offset: number, length: number) => { + const parent = currentParent as Record & { range?: IRange; _parentConfigurationRange?: Mutable }; + if (parent.range) { + const end = model.getPositionAt(offset + length); + parent.range = { + startLineNumber: parent.range.startLineNumber, + startColumn: parent.range.startColumn, + endLineNumber: end.lineNumber, + endColumn: end.column + }; + } + if (parent._parentConfigurationRange) { + const end = model.getPositionAt(offset + length); + parent._parentConfigurationRange.endLineNumber = end.lineNumber; + parent._parentConfigurationRange.endColumn = end.column; + delete parent._parentConfigurationRange; + } + currentParent = previousParents.pop(); + }, + onArrayBegin: (offset: number, length: number) => { + if (currentParent === configuration && previousParents.length === 0) { + previousParents.push(currentParent); + currentProperty = null; + return; + } + const array: unknown[] = []; + onValue(array, offset, length); + previousParents.push(currentParent); + currentParent = array; + currentProperty = null; + }, + onArrayEnd: (offset: number, length: number) => { + const parent = currentParent as { _parentConfigurationRange?: Mutable }; + if (parent._parentConfigurationRange) { + const end = model.getPositionAt(offset + length); + parent._parentConfigurationRange.endLineNumber = end.lineNumber; + parent._parentConfigurationRange.endColumn = end.column; + delete parent._parentConfigurationRange; + } + currentParent = previousParents.pop(); + }, + onLiteralValue: (value: unknown, offset: number, length: number) => { + onValue(value, offset, length); + }, + }; + visit(model.getValue(), visitor); + return configuration; +} + +const languageModelsSchemaId = 'vscode://schemas/language-models'; + +export class ChatLanguageModelsDataContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatLanguageModelsData'; + + constructor( + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IUserDataProfileService userDataProfileService: IUserDataProfileService, + @IUriIdentityService uriIdentityService: IUriIdentityService, + ) { + super(); + const modelsConfigurationFile = uriIdentityService.extUri.joinPath(userDataProfileService.currentProfile.location, 'models.json'); + const registry = Registry.as(JSONExtensions.JSONContribution); + this._register(registry.registerSchemaAssociation(languageModelsSchemaId, modelsConfigurationFile.toString())); + + this.updateSchema(registry); + this._register(this.languageModelsService.onDidChangeLanguageModels(() => this.updateSchema(registry))); + } + + private updateSchema(registry: IJSONContributionRegistry): void { + const vendors = this.languageModelsService.getVendors(); + + const schema: IJSONSchema = { + type: 'array', + items: { + properties: { + vendor: { + type: 'string', + enum: vendors.map(v => v.vendor) + }, + name: { type: 'string' } + }, + allOf: vendors.map(vendor => ({ + if: { + properties: { + vendor: { const: vendor.vendor } + } + }, + then: vendor.configuration + })), + required: ['vendor', 'name'] + } + }; + + registry.registerSchema(languageModelsSchemaId, schema); + } +} diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 42a89de7c3b11..1648acb7d72c6 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -6,24 +6,33 @@ import { SequencerByKey } from '../../../../base/common/async.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IStringDictionary } from '../../../../base/common/collections.js'; +import { getErrorMessage } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { hash } from '../../../../base/common/hash.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { IJSONSchema, TypeFromJsonSchema } from '../../../../base/common/jsonSchema.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { isFalsyOrWhitespace } from '../../../../base/common/strings.js'; +import Severity from '../../../../base/common/severity.js'; +import { format, isFalsyOrWhitespace } from '../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { ExtensionsRegistry } from '../../../services/extensions/common/extensionsRegistry.js'; import { ChatContextKeys } from './actions/chatContextKeys.js'; +import { ILanguageModelsProviderGroup, ILanguageModelsConfigurationService } from './languageModelsConfiguration.js'; export const enum ChatMessageRole { System, @@ -212,7 +221,7 @@ export interface ILanguageModelChatResponse { export interface ILanguageModelChatProvider { readonly onDidChange: Event; - provideLanguageModelChatInfo(options: { silent: boolean }, token: CancellationToken): Promise; + provideLanguageModelChatInfo(options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: any }, token: CancellationToken): Promise; provideTokenCount(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; @@ -259,6 +268,21 @@ export interface ILanguageModelChatMetadataAndIdentifier { identifier: string; } +export interface ILanguageModelChatInfoOptions { + readonly group?: string; + readonly silent: boolean; + readonly configuration?: unknown; +} + +export interface ILanguageModelsGroup { + readonly group?: ILanguageModelsProviderGroup; + readonly models: ILanguageModelChatMetadataAndIdentifier[]; + readonly status?: { + readonly message: string; + readonly severity: Severity; + }; +} + export interface ILanguageModelsService { readonly _serviceBrand: undefined; @@ -274,6 +298,8 @@ export interface ILanguageModelsService { lookupLanguageModel(modelId: string): ILanguageModelChatMetadata | undefined; + fetchLanguageModelGroups(vendor: string): Promise; + /** * Given a selector, returns a list of model identifiers * @param selector The selector to lookup for language models. If the selector is empty, all language models are returned. @@ -287,6 +313,10 @@ export interface ILanguageModelsService { sendChatRequest(modelId: string, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; computeTokenLength(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; + + addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise; + + configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise; } const languageModelChatProviderType = { @@ -301,9 +331,46 @@ const languageModelChatProviderType = { type: 'string', description: localize('vscode.extension.contributes.languageModels.displayName', "The display name of the language model chat provider.") }, + configuration: { + type: 'object', + description: localize('vscode.extension.contributes.languageModels.configuration', "Configuration options for the language model chat provider."), + anyOf: [ + { + $ref: 'http://json-schema.org/draft-07/schema#' + }, + { + properties: { + properties: { + type: 'object', + additionalProperties: { + $ref: 'http://json-schema.org/draft-07/schema#', + properties: { + secret: { + type: 'boolean', + description: localize('vscode.extension.contributes.languageModels.configuration.secret', "Whether the property is a secret.") + } + } + } + }, + additionalProperties: { + $ref: 'http://json-schema.org/draft-07/schema#', + properties: { + secret: { + type: 'boolean', + description: localize('vscode.extension.contributes.languageModels.configuration.secret', "Whether the property is a secret.") + } + } + } + } + } + ] + + }, managementCommand: { type: 'string', - description: localize('vscode.extension.contributes.languageModels.managementCommand', "A command to manage the language model chat provider, e.g. 'Manage Copilot models'. This is used in the chat model picker. If not provided, a gear icon is not rendered during vendor selection.") + description: localize('vscode.extension.contributes.languageModels.managementCommand', "A command to manage the language model chat provider, e.g. 'Manage Copilot models'. This is used in the chat model picker. If not provided, a gear icon is not rendered during vendor selection."), + deprecated: true, + deprecationMessage: localize('vscode.extension.contributes.languageModels.managementCommand.deprecated', "The managementCommand property is deprecated and will be removed in a future release. Use the new configuration property instead.") }, when: { type: 'string', @@ -335,17 +402,21 @@ export const languageModelChatProviderExtensionPoint = ExtensionsRegistry.regist export class LanguageModelsService implements ILanguageModelsService { + private static SECRET_KEY = '${input:{0}}'; + readonly _serviceBrand: undefined; private readonly _store = new DisposableStore(); private readonly _providers = new Map(); - private readonly _modelCache = new Map(); private readonly _vendors = new Map(); + + private readonly _modelsGroups = new Map(); + private readonly _modelCache = new Map(); private readonly _resolveLMSequencer = new SequencerByKey(); - private _modelPickerUserPreferences: Record = {}; + private _modelPickerUserPreferences: IStringDictionary = {}; private readonly _hasUserSelectableModels: IContextKey; - private readonly _contextKeyService: IContextKeyService; + private readonly _onLanguageModelChange = this._store.add(new Emitter()); readonly onDidChangeLanguageModels: Event = this._onLanguageModelChange.event; @@ -353,14 +424,16 @@ export class LanguageModelsService implements ILanguageModelsService { @IExtensionService private readonly _extensionService: IExtensionService, @ILogService private readonly _logService: ILogService, @IStorageService private readonly _storageService: IStorageService, - @IContextKeyService _contextKeyService: IContextKeyService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + @ILanguageModelsConfigurationService private readonly _languageModelsConfigurationService: ILanguageModelsConfigurationService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + @ISecretStorageService private readonly _secretStorageService: ISecretStorageService, ) { this._hasUserSelectableModels = ChatContextKeys.languageModelsAreUserSelectable.bindTo(_contextKeyService); - this._contextKeyService = _contextKeyService; - this._modelPickerUserPreferences = this._storageService.getObject>('chatModelPickerPreferences', StorageScope.PROFILE, this._modelPickerUserPreferences); - // TODO @lramos15 - Remove after a few releases, as this is just cleaning a bad storage state + this._modelPickerUserPreferences = this._storageService.getObject>('chatModelPickerPreferences', StorageScope.PROFILE, this._modelPickerUserPreferences); + const entitlementChangeHandler = () => { if ((this._chatEntitlementService.entitlement === ChatEntitlement.Business || this._chatEntitlementService.entitlement === ChatEntitlement.Enterprise) && !this._chatEntitlementService.isInternal) { this._modelPickerUserPreferences = {}; @@ -371,9 +444,7 @@ export class LanguageModelsService implements ILanguageModelsService { entitlementChangeHandler(); this._store.add(this._chatEntitlementService.onDidChangeEntitlement(entitlementChangeHandler)); - this._store.add(this.onDidChangeLanguageModels(() => { - this._hasUserSelectableModels.set(this._modelCache.size > 0 && Array.from(this._modelCache.values()).some(model => model.isUserSelectable)); - })); + this._store.add(this.onDidChangeLanguageModels(() => this._hasUserSelectableModels.set(this._modelCache.size > 0 && Array.from(this._modelCache.values()).some(model => model.isUserSelectable)))); this._store.add(languageModelChatProviderExtensionPoint.setHandler((extensions) => { @@ -414,11 +485,6 @@ export class LanguageModelsService implements ILanguageModelsService { }); } - dispose() { - this._store.dispose(); - this._providers.clear(); - } - updateModelPickerPreference(modelIdentifier: string, showInModelPicker: boolean): void { const model = this._modelCache.get(modelIdentifier); if (!model) { @@ -462,52 +528,110 @@ export class LanguageModelsService implements ILanguageModelsService { return model; } - private _clearModelCache(vendor: string): void { - for (const [id, model] of this._modelCache.entries()) { - if (model.vendor === vendor) { - this._modelCache.delete(id); - } + private async _resolveAllLanguageModels(vendorId: string, silent: boolean): Promise { + + const vendor = this._vendors.get(vendorId); + + if (!vendor) { + return; } - } - private async _resolveLanguageModels(vendor: string, silent: boolean): Promise { // Activate extensions before requesting to resolve the models - await this._extensionService.activateByEvent(`onLanguageModelChatProvider:${vendor}`); - const provider = this._providers.get(vendor); + await this._extensionService.activateByEvent(`onLanguageModelChatProvider:${vendorId}`); + + const provider = this._providers.get(vendorId); if (!provider) { - this._logService.warn(`[LM] No provider registered for vendor ${vendor}`); + this._logService.warn(`[LM] No provider registered for vendor ${vendorId}`); return; } - return this._resolveLMSequencer.queue(vendor, async () => { + + return this._resolveLMSequencer.queue(vendorId, async () => { + + const allModels: ILanguageModelChatMetadataAndIdentifier[] = []; + const languageModelsGroups: ILanguageModelsGroup[] = []; + try { - let modelsAndIdentifiers = await provider.provideLanguageModelChatInfo({ silent }, CancellationToken.None); - // This is a bit of a hack, when prompting user if the provider returns any models that are user selectable then we only want to show those and not the entire model list - if (!silent && modelsAndIdentifiers.some(m => m.metadata.isUserSelectable)) { - modelsAndIdentifiers = modelsAndIdentifiers.filter(m => m.metadata.isUserSelectable || this._modelPickerUserPreferences[m.identifier] === true); + const models = await this._resolveLanguageModels(vendorId, provider, { silent }); + if (models.length) { + allModels.push(...models); + languageModelsGroups.push({ models }); } - this._clearModelCache(vendor); - for (const modelAndIdentifier of modelsAndIdentifiers) { - if (this._modelCache.has(modelAndIdentifier.identifier)) { - this._logService.warn(`[LM] Model ${modelAndIdentifier.identifier} is already registered. Skipping.`); - continue; + } catch (error) { + languageModelsGroups.push({ + models: [], + status: { + message: getErrorMessage(error), + severity: Severity.Error } - this._modelCache.set(modelAndIdentifier.identifier, modelAndIdentifier.metadata); + }); + } + + const groups = this._languageModelsConfigurationService.getLanguageModelsProviderGroups(); + for (const group of groups) { + if (group.vendor !== vendorId) { + continue; + } + + const configuration = await this._resolveConfiguration(group, vendor.configuration); + + try { + const models = await this._resolveLanguageModels(vendorId, provider, { group: group.name, silent, configuration }); + if (models.length) { + allModels.push(...models); + languageModelsGroups.push({ group, models }); + } + } catch (error) { + languageModelsGroups.push({ + group, + models: [], + status: { + message: getErrorMessage(error), + severity: Severity.Error + } + }); } - this._logService.trace(`[LM] Resolved language models for vendor ${vendor}`, modelsAndIdentifiers); - } catch (error) { - this._logService.error(`[LM] Error resolving language models for vendor ${vendor}:`, error); } - this._onLanguageModelChange.fire(vendor); + + this._modelsGroups.set(vendorId, languageModelsGroups); + this._clearModelCache(vendorId); + for (const model of allModels) { + this._modelCache.set(model.identifier, model.metadata); + } + this._onLanguageModelChange.fire(vendorId); }); } + private async _resolveLanguageModels(vendor: string, provider: ILanguageModelChatProvider, options: ILanguageModelChatInfoOptions): Promise { + let models = await provider.provideLanguageModelChatInfo(options, CancellationToken.None); + if (models.length) { + // This is a bit of a hack, when prompting user if the provider returns any models that are user selectable then we only want to show those and not the entire model list + if (!options.silent && models.some(m => m.metadata.isUserSelectable)) { + models = models.filter(m => m.metadata.isUserSelectable || this._modelPickerUserPreferences[m.identifier] === true); + } + + for (const { identifier } of models) { + if (this._modelCache.has(identifier)) { + this._logService.warn(`[LM] Model ${identifier} is already registered. Skipping.`); + continue; + } + } + this._logService.trace(`[LM] Resolved language models for vendor ${vendor}`, models); + } + return models; + } + + async fetchLanguageModelGroups(vendor: string): Promise { + await this._resolveAllLanguageModels(vendor, true); + return this._modelsGroups.get(vendor) ?? []; + } + async selectLanguageModels(selector: ILanguageModelChatSelector, allowPromptingUser?: boolean): Promise { if (selector.vendor) { - await this._resolveLanguageModels(selector.vendor, !allowPromptingUser); + await this._resolveAllLanguageModels(selector.vendor, !allowPromptingUser); } else { const allVendors = Array.from(this._vendors.keys()); - await Promise.all(allVendors.map(vendor => this._resolveLanguageModels(vendor, !allowPromptingUser))); + await Promise.all(allVendors.map(vendor => this._resolveAllLanguageModels(vendor, !allowPromptingUser))); } const result: string[] = []; @@ -539,11 +663,11 @@ export class LanguageModelsService implements ILanguageModelsService { this._providers.set(vendor, provider); if (this._hasStoredModelForVendor(vendor)) { - this._resolveLanguageModels(vendor, true); + this._resolveAllLanguageModels(vendor, true); } - const modelChangeListener = provider.onDidChange(async () => { - await this._resolveLanguageModels(vendor, true); + const modelChangeListener = provider.onDidChange(() => { + this._resolveAllLanguageModels(vendor, true); }); return toDisposable(() => { @@ -574,4 +698,318 @@ export class LanguageModelsService implements ILanguageModelsService { } return provider.provideTokenCount(modelId, message, token); } + + async configureLanguageModelsProviderGroup(vendorId: string, providerGroupName?: string): Promise { + + const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); + if (!vendor) { + throw new Error(`Vendor ${vendorId} not found.`); + } + + if (vendor.managementCommand) { + await this.selectLanguageModels({ vendor: vendor.vendor }, true); + return; + } + + const languageModelProviderGroups = this._languageModelsConfigurationService.getLanguageModelsProviderGroups(); + const existing = languageModelProviderGroups.find(g => g.vendor === vendorId && g.name === providerGroupName); + + const configuration = vendor.configuration ? await this.promptForConfiguration(vendor.configuration, existing) : undefined; + if (vendor.configuration && !configuration) { + return; + } + + const name = await this.promptForName(languageModelProviderGroups, vendor, existing); + if (!name) { + return; + } + + + const languageModelProviderGroup = await this._resolveLanguageModelProviderGroup(name, vendorId, configuration, vendor.configuration); + const saved = existing + ? await this._languageModelsConfigurationService.updateLanguageModelsProviderGroup(existing, languageModelProviderGroup) + : await this._languageModelsConfigurationService.addLanguageModelsProviderGroup(languageModelProviderGroup); + + if (vendor.configuration && this.canConfigure(configuration ?? {}, vendor.configuration)) { + await this._languageModelsConfigurationService.configureLanguageModels(saved.range); + } + } + + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { + const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); + if (!vendor) { + throw new Error(`Vendor ${vendorId} not found.`); + } + + const languageModelProviderGroup = await this._resolveLanguageModelProviderGroup(name, vendorId, configuration, vendor.configuration); + await this._languageModelsConfigurationService.addLanguageModelsProviderGroup(languageModelProviderGroup); + } + + private canConfigure(configuration: IStringDictionary, schema: IJSONSchema): boolean { + if (schema.additionalProperties) { + return true; + } + if (!schema.properties) { + return false; + } + for (const property of Object.keys(schema.properties)) { + if (configuration[property] === undefined) { + return true; + } + } + return false; + } + + private async promptForName(languageModelProviderGroups: readonly ILanguageModelsProviderGroup[], vendor: IUserFriendlyLanguageModel, existing: ILanguageModelsProviderGroup | undefined): Promise { + let providerGroupName = existing?.name; + if (!providerGroupName) { + providerGroupName = vendor.displayName; + let count = 1; + while (languageModelProviderGroups.some(g => g.vendor === vendor.vendor && g.name === providerGroupName)) { + count++; + providerGroupName = `${vendor.displayName} ${count}`; + } + } + + let result: string | undefined; + const disposables = new DisposableStore(); + try { + await new Promise(resolve => { + const inputBox = disposables.add(this._quickInputService.createInputBox()); + inputBox.title = localize('configureLanguageModelGroup', "{0}: Group Name", providerGroupName); + inputBox.placeholder = localize('languageModelGroupName', "Enter a name for the group"); + inputBox.value = providerGroupName; + inputBox.ignoreFocusOut = true; + + disposables.add(inputBox.onDidChangeValue(value => { + if (!value) { + inputBox.validationMessage = localize('enterName', "Please enter a name"); + inputBox.severity = Severity.Error; + return; + } + if (!existing && languageModelProviderGroups.some(g => g.name === value)) { + inputBox.validationMessage = localize('nameExists', "A language models group with this name already exists"); + inputBox.severity = Severity.Error; + return; + } + inputBox.validationMessage = undefined; + inputBox.severity = Severity.Ignore; + })); + disposables.add(inputBox.onDidAccept(async () => { + result = inputBox.value; + inputBox.hide(); + })); + disposables.add(inputBox.onDidHide(() => resolve())); + inputBox.show(); + }); + } finally { + disposables.dispose(); + } + return result; + } + + private async promptForConfiguration(configuration: IJSONSchema, existing: ILanguageModelsProviderGroup | undefined): Promise | undefined> { + if (!configuration.properties) { + return; + } + + const result: IStringDictionary = {}; + + for (const property of Object.keys(configuration.properties)) { + const propertySchema = configuration.properties[property]; + const value = await this.promptForValue(property, propertySchema, existing); + if (value !== undefined) { + result[property] = value; + } else if (configuration.required?.includes(property)) { + return undefined; + } + } + + return result; + } + + private async promptForValue(property: string, propertySchema: IJSONSchema | undefined, existing: ILanguageModelsProviderGroup | undefined): Promise { + if (!propertySchema || typeof propertySchema === 'boolean') { + return undefined; + } + + if (propertySchema.type === 'array' && propertySchema.items && !Array.isArray(propertySchema.items) && propertySchema.items.enum) { + const selectedItems = await this.promptForArray(property, propertySchema); + if (selectedItems === undefined) { + return undefined; + } + return selectedItems; + } + + if (propertySchema.type !== 'string' && propertySchema.type !== 'number' && propertySchema.type !== 'integer' && propertySchema.type !== 'boolean') { + return undefined; + } + + + const value = await this.promptForInput(property, propertySchema, existing); + if (value === undefined) { + return undefined; + } + return value; + } + + private async promptForArray(property: string, propertySchema: IJSONSchema): Promise { + if (!propertySchema.items || Array.isArray(propertySchema.items) || !propertySchema.items.enum) { + return undefined; + } + const items = propertySchema.items.enum; + const disposables = new DisposableStore(); + try { + return await new Promise(resolve => { + const quickPick = disposables.add(this._quickInputService.createQuickPick()); + quickPick.title = propertySchema.description ?? localize('selectProperty', "Select {0}", property); + quickPick.items = items.map(item => ({ label: item })); + quickPick.placeholder = propertySchema.description ?? localize('selectValue', "Select value for {0}", property); + quickPick.canSelectMany = true; + quickPick.ignoreFocusOut = true; + + disposables.add(quickPick.onDidAccept(() => { + resolve(quickPick.selectedItems.map(item => item.label)); + quickPick.hide(); + })); + disposables.add(quickPick.onDidHide(() => { + resolve(undefined); + })); + quickPick.show(); + }); + } finally { + disposables.dispose(); + } + } + + private async promptForInput(property: string, propertySchema: IJSONSchema, existing: ILanguageModelsProviderGroup | undefined): Promise { + const disposables = new DisposableStore(); + try { + const value = await new Promise(resolve => { + const inputBox = disposables.add(this._quickInputService.createInputBox()); + inputBox.title = propertySchema.description ?? localize('enterProperty', "Enter {0}", property); + inputBox.placeholder = localize('enterValue', "Enter value for {0}", property); + inputBox.password = !!propertySchema.secret; + inputBox.ignoreFocusOut = true; + if (existing?.[property]) { + inputBox.value = String(existing?.[property]); + } else if (propertySchema.default) { + inputBox.value = String(propertySchema.default); + } + + disposables.add(inputBox.onDidChangeValue(value => { + if (!value && !propertySchema.default) { + inputBox.validationMessage = localize('valueRequired', "Value is required"); + inputBox.severity = Severity.Error; + return; + } + if (propertySchema.type === 'number' || propertySchema.type === 'integer') { + if (isNaN(Number(value))) { + inputBox.validationMessage = localize('numberRequired', "Please enter a number"); + inputBox.severity = Severity.Error; + return; + } + } + if (propertySchema.type === 'boolean') { + if (value !== 'true' && value !== 'false') { + inputBox.validationMessage = localize('booleanRequired', "Please enter true or false"); + inputBox.severity = Severity.Error; + return; + } + } + inputBox.validationMessage = undefined; + inputBox.severity = Severity.Ignore; + })); + + disposables.add(inputBox.onDidAccept(() => { + resolve(inputBox.value); + inputBox.hide(); + })); + + disposables.add(inputBox.onDidHide(() => resolve(undefined))); + + inputBox.show(); + }); + + if (!value) { + return undefined; // User cancelled + } + + if (propertySchema.type === 'number' || propertySchema.type === 'integer') { + return Number(value); + } else if (propertySchema.type === 'boolean') { + return value === 'true'; + } else { + return value; + } + + } finally { + disposables.dispose(); + } + } + + private encodeSecretKey(property: string): string { + return format(LanguageModelsService.SECRET_KEY, property); + } + + private decodeSecretKey(secretInput: unknown): string | undefined { + if (!isString(secretInput)) { + return undefined; + } + return secretInput.substring(secretInput.indexOf(':') + 1, secretInput.length - 1); + } + + private _clearModelCache(vendor: string): void { + for (const [id, model] of this._modelCache.entries()) { + if (model.vendor === vendor) { + this._modelCache.delete(id); + } + } + } + + private async _resolveConfiguration(group: ILanguageModelsProviderGroup, schema: IJSONSchema | undefined): Promise> { + if (!schema) { + return {}; + } + + const result: IStringDictionary = {}; + for (const key in group) { + if (key === 'vendor' || key === 'name' || key === 'range') { + continue; + } + let value = group[key]; + if (schema.properties?.[key]?.secret) { + const secretKey = this.decodeSecretKey(value); + value = secretKey ? await this._secretStorageService.get(secretKey) : undefined; + } + result[key] = value; + } + + return result; + } + + private async _resolveLanguageModelProviderGroup(name: string, vendor: string, configuration: IStringDictionary | undefined, schema: IJSONSchema | undefined): Promise { + if (!schema) { + return { name, vendor }; + } + + const result: IStringDictionary = {}; + for (const key in configuration) { + let value = configuration[key]; + if (schema.properties?.[key]?.secret && isString(value)) { + const secretKey = `secret.${hash(generateUuid())}`; + await this._secretStorageService.set(secretKey, value); + value = this.encodeSecretKey(secretKey); + } + result[key] = value; + } + + return { name, vendor, ...result }; + } + + dispose() { + this._store.dispose(); + this._providers.clear(); + } + } diff --git a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts new file mode 100644 index 0000000000000..57baba9e9c7dc --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { IStringDictionary } from '../../../../base/common/collections.js'; + +export const ILanguageModelsConfigurationService = createDecorator('ILanguageModelsConfigurationService'); + +export interface ILanguageModelsConfigurationService { + readonly _serviceBrand: undefined; + + readonly onDidChangeLanguageModelGroups: Event; + + getLanguageModelsProviderGroups(): readonly ILanguageModelsProviderGroup[]; + + addLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise; + + updateLanguageModelsProviderGroup(from: ILanguageModelsProviderGroup, to: ILanguageModelsProviderGroup): Promise; + + removeLanguageModelsProviderGroup(languageModelGroup: ILanguageModelsProviderGroup): Promise; + + configureLanguageModels(range?: IRange): Promise; +} + +export interface ILanguageModelsProviderGroup extends IStringDictionary { + readonly name: string; + readonly vendor: string; + readonly range?: IRange; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 4e4ccf1db5b35..53ab0d26203d5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -5,13 +5,16 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../../common/languageModels.js'; -import { ChatModelGroup, ChatModelsViewModel, IModelItemEntry, IVendorItemEntry, isVendorEntry, isGroupEntry, IGroupItemEntry } from '../../../browser/chatManagement/chatModelsViewModel.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../../common/languageModels.js'; +import { ChatModelGroup, ChatModelsViewModel, ILanguageModelEntry, ILanguageModelProviderEntry, isLanguageModelProviderEntry, isLanguageModelGroupEntry, ILanguageModelGroupEntry } from '../../../browser/chatManagement/chatModelsViewModel.js'; import { IChatEntitlementService, ChatEntitlement } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { IStringDictionary } from '../../../../../../base/common/collections.js'; +import { ILanguageModelsConfigurationService } from '../../../common/languageModelsConfiguration.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; class MockLanguageModelsService implements ILanguageModelsService { _serviceBrand: undefined; @@ -19,6 +22,7 @@ class MockLanguageModelsService implements ILanguageModelsService { private vendors: IUserFriendlyLanguageModel[] = []; private models = new Map(); private modelsByVendor = new Map(); + private modelGroups = new Map(); private readonly _onDidChangeLanguageModels = new Emitter(); readonly onDidChangeLanguageModels = this._onDidChangeLanguageModels.event; @@ -26,6 +30,7 @@ class MockLanguageModelsService implements ILanguageModelsService { addVendor(vendor: IUserFriendlyLanguageModel): void { this.vendors.push(vendor); this.modelsByVendor.set(vendor.vendor, []); + this.modelGroups.set(vendor.vendor, []); } addModel(vendorId: string, identifier: string, metadata: ILanguageModelChatMetadata): void { @@ -33,6 +38,20 @@ class MockLanguageModelsService implements ILanguageModelsService { const models = this.modelsByVendor.get(vendorId) || []; models.push(identifier); this.modelsByVendor.set(vendorId, models); + + // Add to model groups - create a single default group per vendor + const groups = this.modelGroups.get(vendorId) || []; + if (groups.length === 0) { + groups.push({ + group: { + vendor: vendorId, + name: this.vendors.find(v => v.vendor === vendorId)?.displayName || 'Default' + }, + models: [] + }); + } + groups[0].models.push({ identifier, metadata }); + this.modelGroups.set(vendorId, groups); } registerLanguageModelProvider(vendor: string, provider: ILanguageModelChatProvider): IDisposable { @@ -72,7 +91,7 @@ class MockLanguageModelsService implements ILanguageModelsService { clearContributedSessionModels(): void { } - async selectLanguageModels(selector: ILanguageModelChatSelector, allowHidden?: boolean): Promise { + async selectLanguageModels(selector: ILanguageModelChatSelector): Promise { if (selector.vendor) { return this.modelsByVendor.get(selector.vendor) || []; } @@ -86,6 +105,16 @@ class MockLanguageModelsService implements ILanguageModelsService { computeTokenLength(): Promise { throw new Error('Method not implemented.'); } + + async configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise { + } + + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { + } + + async fetchLanguageModelGroups(vendor: string): Promise { + return this.modelGroups.get(vendor) || []; + } } class MockChatEntitlementService implements IChatEntitlementService { @@ -141,13 +170,12 @@ class MockChatEntitlementService implements IChatEntitlementService { } suite('ChatModelsViewModel', () => { - let store: DisposableStore; + const store = ensureNoDisposablesAreLeakedInTestSuite(); let languageModelsService: MockLanguageModelsService; let chatEntitlementService: MockChatEntitlementService; let viewModel: ChatModelsViewModel; setup(async () => { - store = new DisposableStore(); languageModelsService = new MockLanguageModelsService(); chatEntitlementService = new MockChatEntitlementService(); @@ -156,14 +184,16 @@ suite('ChatModelsViewModel', () => { vendor: 'copilot', displayName: 'GitHub Copilot', managementCommand: undefined, - when: undefined + when: undefined, + configuration: undefined }); languageModelsService.addVendor({ vendor: 'openai', displayName: 'OpenAI', managementCommand: undefined, - when: undefined + when: undefined, + configuration: undefined }); languageModelsService.addModel('copilot', 'copilot-gpt-4', { @@ -240,176 +270,179 @@ suite('ChatModelsViewModel', () => { viewModel = store.add(new ChatModelsViewModel( languageModelsService, - chatEntitlementService + new class extends mock() { + override get onDidChangeLanguageModelGroups() { + return Event.None; + } + }, + chatEntitlementService, )); await viewModel.refresh(); }); - teardown(() => { - store.dispose(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - test('should fetch all models without filters', () => { const results = viewModel.filter(''); // Should have 2 vendor entries and 4 model entries (grouped by vendor) assert.strictEqual(results.length, 6); - const vendors = results.filter(isVendorEntry); + const vendors = results.filter(isLanguageModelProviderEntry); assert.strictEqual(vendors.length, 2); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 4); }); - test('should filter by provider name', () => { - const results = viewModel.filter('@provider:copilot'); + test('should filter by provider name (vendor ID and display name)', () => { + const resultsByCopilotId = viewModel.filter('@provider:copilot'); + assert.strictEqual(resultsByCopilotId.length, 3); + assert.strictEqual(resultsByCopilotId[0].type, 'vendor'); + assert.strictEqual(resultsByCopilotId[0].vendorEntry.vendor.vendor, 'copilot'); + assert.strictEqual(resultsByCopilotId[1].type, 'model'); + assert.strictEqual(resultsByCopilotId[1].model.identifier, 'copilot-gpt-4'); + assert.strictEqual(resultsByCopilotId[2].type, 'model'); + assert.strictEqual(resultsByCopilotId[2].model.identifier, 'copilot-gpt-4o'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; - assert.strictEqual(models.length, 2); - assert.ok(models.every(m => m.modelEntry.vendor === 'copilot')); - }); - - test('should filter by provider display name', () => { - const results = viewModel.filter('@provider:OpenAI'); - - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; - assert.strictEqual(models.length, 2); - assert.ok(models.every(m => m.modelEntry.vendor === 'openai')); + const resultsByOpenAIName = viewModel.filter('@provider:OpenAI'); + assert.strictEqual(resultsByOpenAIName.length, 3); + assert.strictEqual(resultsByOpenAIName[0].type, 'vendor'); + assert.strictEqual(resultsByOpenAIName[0].vendorEntry.vendor.vendor, 'openai'); + assert.strictEqual(resultsByOpenAIName[1].type, 'model'); + assert.strictEqual(resultsByOpenAIName[1].model.identifier, 'openai-gpt-3.5'); + assert.strictEqual(resultsByOpenAIName[2].type, 'model'); + assert.strictEqual(resultsByOpenAIName[2].model.identifier, 'openai-gpt-4-vision'); }); test('should filter by multiple providers with OR logic', () => { const results = viewModel.filter('@provider:copilot @provider:openai'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 4); }); test('should filter by single capability - tools', () => { const results = viewModel.filter('@capability:tools'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 3); - assert.ok(models.every(m => m.modelEntry.metadata.capabilities?.toolCalling === true)); + assert.ok(models.every(m => m.model.metadata.capabilities?.toolCalling === true)); }); test('should filter by single capability - vision', () => { const results = viewModel.filter('@capability:vision'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 3); - assert.ok(models.every(m => m.modelEntry.metadata.capabilities?.vision === true)); + assert.ok(models.every(m => m.model.metadata.capabilities?.vision === true)); }); test('should filter by single capability - agent', () => { const results = viewModel.filter('@capability:agent'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 1); - assert.strictEqual(models[0].modelEntry.metadata.id, 'gpt-4o'); + assert.strictEqual(models[0].model.metadata.id, 'gpt-4o'); }); test('should filter by multiple capabilities with AND logic', () => { const results = viewModel.filter('@capability:tools @capability:vision'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; // Should only return models that have BOTH tools and vision assert.strictEqual(models.length, 2); assert.ok(models.every(m => - m.modelEntry.metadata.capabilities?.toolCalling === true && - m.modelEntry.metadata.capabilities?.vision === true + m.model.metadata.capabilities?.toolCalling === true && + m.model.metadata.capabilities?.vision === true )); }); test('should filter by three capabilities with AND logic', () => { const results = viewModel.filter('@capability:tools @capability:vision @capability:agent'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; // Should only return gpt-4o which has all three assert.strictEqual(models.length, 1); - assert.strictEqual(models[0].modelEntry.metadata.id, 'gpt-4o'); + assert.strictEqual(models[0].model.metadata.id, 'gpt-4o'); }); test('should return no results when filtering by incompatible capabilities', () => { const results = viewModel.filter('@capability:vision @capability:agent'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; // Only gpt-4o has both vision and agent, but gpt-4-vision doesn't have agent assert.strictEqual(models.length, 1); - assert.strictEqual(models[0].modelEntry.metadata.id, 'gpt-4o'); + assert.strictEqual(models[0].model.metadata.id, 'gpt-4o'); }); test('should filter by visibility - visible:true', () => { const results = viewModel.filter('@visible:true'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 3); - assert.ok(models.every(m => m.modelEntry.metadata.isUserSelectable === true)); + assert.ok(models.every(m => m.model.metadata.isUserSelectable === true)); }); test('should filter by visibility - visible:false', () => { const results = viewModel.filter('@visible:false'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 1); - assert.strictEqual(models[0].modelEntry.metadata.isUserSelectable, false); + assert.strictEqual(models[0].model.metadata.isUserSelectable, false); }); test('should combine provider and capability filters', () => { const results = viewModel.filter('@provider:copilot @capability:vision'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 2); assert.ok(models.every(m => - m.modelEntry.vendor === 'copilot' && - m.modelEntry.metadata.capabilities?.vision === true + m.model.provider.vendor.vendor === 'copilot' && + m.model.metadata.capabilities?.vision === true )); }); test('should combine provider, capability, and visibility filters', () => { const results = viewModel.filter('@provider:openai @capability:vision @visible:false'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 1); - assert.strictEqual(models[0].modelEntry.metadata.id, 'gpt-4-vision'); + assert.strictEqual(models[0].model.metadata.id, 'gpt-4-vision'); }); test('should filter by text matching model name', () => { const results = viewModel.filter('GPT-4o'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 1); - assert.strictEqual(models[0].modelEntry.metadata.name, 'GPT-4o'); + assert.strictEqual(models[0].model.metadata.name, 'GPT-4o'); assert.ok(models[0].modelNameMatches); }); test('should filter by text matching model id', () => { - const results = viewModel.filter('copilot-gpt-4o'); + const results = viewModel.filter('gpt-4o'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 1); - assert.strictEqual(models[0].modelEntry.identifier, 'copilot-gpt-4o'); + assert.strictEqual(models[0].model.identifier, 'copilot-gpt-4o'); assert.ok(models[0].modelIdMatches); }); test('should filter by text matching vendor name', () => { const results = viewModel.filter('GitHub'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 2); - assert.ok(models.every(m => m.modelEntry.vendorDisplayName === 'GitHub Copilot')); + assert.ok(models.every(m => m.model.provider.group.name === 'GitHub Copilot')); }); test('should combine text search with capability filter', () => { const results = viewModel.filter('@capability:tools GPT'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; // Should match all models with tools capability and 'GPT' in name assert.strictEqual(models.length, 3); - assert.ok(models.every(m => m.modelEntry.metadata.capabilities?.toolCalling === true)); + assert.ok(models.every(m => m.model.metadata.capabilities?.toolCalling === true)); }); test('should handle empty search value', () => { @@ -429,28 +462,27 @@ suite('ChatModelsViewModel', () => { test('should match capability text in free text search', () => { const results = viewModel.filter('vision'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; // Should match models that have vision capability or "vision" in their name assert.ok(models.length > 0); assert.ok(models.every(m => - m.modelEntry.metadata.capabilities?.vision === true || - m.modelEntry.metadata.name.toLowerCase().includes('vision') + m.model.metadata.capabilities?.vision === true || + m.model.metadata.name.toLowerCase().includes('vision') )); }); test('should toggle vendor collapsed state', () => { - const vendorEntry = viewModel.viewModelEntries.find(r => isVendorEntry(r) && r.vendorEntry.vendor === 'copilot') as IVendorItemEntry; + const vendorEntry = viewModel.viewModelEntries.find(r => isLanguageModelProviderEntry(r) && r.vendorEntry.vendor.vendor === 'copilot') as ILanguageModelProviderEntry; viewModel.toggleCollapsed(vendorEntry); const results = viewModel.filter(''); - const copilotVendor = results.find(r => isVendorEntry(r) && (r as IVendorItemEntry).vendorEntry.vendor === 'copilot') as IVendorItemEntry; - + const copilotVendor = results.find(r => isLanguageModelProviderEntry(r) && (r as ILanguageModelProviderEntry).vendorEntry.vendor.vendor === 'copilot') as ILanguageModelProviderEntry; assert.ok(copilotVendor); assert.strictEqual(copilotVendor.collapsed, true); // Models should not be shown when vendor is collapsed const copilotModelsAfterCollapse = results.filter(r => - !isVendorEntry(r) && (r as IModelItemEntry).modelEntry.vendor === 'copilot' + !isLanguageModelProviderEntry(r) && (r as ILanguageModelEntry).model.provider.vendor.vendor === 'copilot' ); assert.strictEqual(copilotModelsAfterCollapse.length, 0); @@ -458,7 +490,7 @@ suite('ChatModelsViewModel', () => { viewModel.toggleCollapsed(vendorEntry); const resultsAfterExpand = viewModel.filter(''); const copilotModelsAfterExpand = resultsAfterExpand.filter(r => - !isVendorEntry(r) && (r as IModelItemEntry).modelEntry.vendor === 'copilot' + !isLanguageModelProviderEntry(r) && (r as ILanguageModelEntry).model.provider.vendor.vendor === 'copilot' ); assert.strictEqual(copilotModelsAfterExpand.length, 2); }); @@ -491,10 +523,10 @@ suite('ChatModelsViewModel', () => { test('should remove filter keywords from text search', () => { const results = viewModel.filter('@provider:copilot @capability:vision GPT'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; // Should only search 'GPT' in model names, not the filter keywords assert.strictEqual(models.length, 2); - assert.ok(models.every(m => m.modelEntry.vendor === 'copilot')); + assert.ok(models.every(m => m.model.provider.vendor.vendor === 'copilot')); }); test('should handle case-insensitive capability matching', () => { @@ -502,9 +534,9 @@ suite('ChatModelsViewModel', () => { const results2 = viewModel.filter('@capability:tools'); const results3 = viewModel.filter('@capability:Tools'); - const models1 = results1.filter(r => !isVendorEntry(r)); - const models2 = results2.filter(r => !isVendorEntry(r)); - const models3 = results3.filter(r => !isVendorEntry(r)); + const models1 = results1.filter(r => !isLanguageModelProviderEntry(r)); + const models2 = results2.filter(r => !isLanguageModelProviderEntry(r)); + const models3 = results3.filter(r => !isLanguageModelProviderEntry(r)); assert.strictEqual(models1.length, models2.length); assert.strictEqual(models2.length, models3.length); @@ -514,8 +546,8 @@ suite('ChatModelsViewModel', () => { const resultsTools = viewModel.filter('@capability:tools'); const resultsToolCalling = viewModel.filter('@capability:toolcalling'); - const modelsTools = resultsTools.filter(r => !isVendorEntry(r)); - const modelsToolCalling = resultsToolCalling.filter(r => !isVendorEntry(r)); + const modelsTools = resultsTools.filter(r => !isLanguageModelProviderEntry(r)); + const modelsToolCalling = resultsToolCalling.filter(r => !isLanguageModelProviderEntry(r)); assert.strictEqual(modelsTools.length, modelsToolCalling.length); }); @@ -524,8 +556,8 @@ suite('ChatModelsViewModel', () => { const resultsAgent = viewModel.filter('@capability:agent'); const resultsAgentMode = viewModel.filter('@capability:agentmode'); - const modelsAgent = resultsAgent.filter(r => !isVendorEntry(r)); - const modelsAgentMode = resultsAgentMode.filter(r => !isVendorEntry(r)); + const modelsAgent = resultsAgent.filter(r => !isLanguageModelProviderEntry(r)); + const modelsAgentMode = resultsAgentMode.filter(r => !isLanguageModelProviderEntry(r)); assert.strictEqual(modelsAgent.length, modelsAgentMode.length); }); @@ -533,7 +565,7 @@ suite('ChatModelsViewModel', () => { test('should include matched capabilities in results', () => { const results = viewModel.filter('@capability:tools @capability:vision'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.ok(models.length > 0); for (const model of models) { @@ -544,14 +576,14 @@ suite('ChatModelsViewModel', () => { } }); - // Helper function to create a single vendor test environment - function createSingleVendorViewModel(store: DisposableStore, chatEntitlementService: IChatEntitlementService, includeSecondModel: boolean = true): { service: MockLanguageModelsService; viewModel: ChatModelsViewModel } { + function createSingleVendorViewModel(chatEntitlementService: IChatEntitlementService, includeSecondModel: boolean = true): { service: MockLanguageModelsService; viewModel: ChatModelsViewModel } { const service = new MockLanguageModelsService(); service.addVendor({ vendor: 'copilot', displayName: 'GitHub Copilot', managementCommand: undefined, - when: undefined + when: undefined, + configuration: undefined }); service.addModel('copilot', 'copilot-gpt-4', { @@ -592,23 +624,27 @@ suite('ChatModelsViewModel', () => { }); } - const viewModel = store.add(new ChatModelsViewModel(service, chatEntitlementService)); + const viewModel = store.add(new ChatModelsViewModel(service, new class extends mock() { + override get onDidChangeLanguageModelGroups() { + return Event.None; + } + }, chatEntitlementService)); return { service, viewModel }; } test('should not show vendor header when only one vendor exists', async () => { - const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(store, chatEntitlementService); + const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(chatEntitlementService); await singleVendorViewModel.refresh(); const results = singleVendorViewModel.filter(''); // Should have only model entries, no vendor entry - const vendors = results.filter(isVendorEntry); + const vendors = results.filter(isLanguageModelProviderEntry); assert.strictEqual(vendors.length, 0, 'Should not show vendor header when only one vendor exists'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 2, 'Should show all models'); - assert.ok(models.every(m => m.modelEntry.vendor === 'copilot')); + assert.ok(models.every(m => m.model.provider.vendor.vendor === 'copilot')); }); test('should show vendor headers when multiple vendors exist', () => { @@ -616,46 +652,42 @@ suite('ChatModelsViewModel', () => { const results = viewModel.filter(''); // Should have 2 vendor entries and 4 model entries (grouped by vendor) - const vendors = results.filter(isVendorEntry); + const vendors = results.filter(isLanguageModelProviderEntry); assert.strictEqual(vendors.length, 2, 'Should show vendor headers when multiple vendors exist'); - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 4); }); test('should filter single vendor models by capability', async () => { - const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(store, chatEntitlementService); + const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(chatEntitlementService); await singleVendorViewModel.refresh(); const results = singleVendorViewModel.filter('@capability:agent'); // Should not show vendor header - const vendors = results.filter(isVendorEntry); + const vendors = results.filter(isLanguageModelProviderEntry); assert.strictEqual(vendors.length, 0, 'Should not show vendor header'); // Should only show the model with agent capability - const models = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r)) as IModelItemEntry[]; + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; assert.strictEqual(models.length, 1); - assert.strictEqual(models[0].modelEntry.metadata.id, 'gpt-4o'); + assert.strictEqual(models[0].model.metadata.id, 'gpt-4o'); }); - test('should always place copilot vendor at the top', () => { - const results = viewModel.filter(''); + test('should always place copilot vendor at the top when multiple vendors exist', async () => { + // Test with default setup (copilot and openai) + let results = viewModel.filter(''); + let vendors = results.filter(isLanguageModelProviderEntry) as ILanguageModelProviderEntry[]; + assert.strictEqual(vendors[0].vendorEntry.vendor.vendor, 'copilot'); - const vendors = results.filter(isVendorEntry) as IVendorItemEntry[]; - assert.ok(vendors.length >= 2); - - // First vendor should always be copilot - assert.strictEqual(vendors[0].vendorEntry.vendor, 'copilot'); - }); - - test('should maintain copilot at top with multiple vendors', async () => { // Add more vendors to ensure sorting works correctly languageModelsService.addVendor({ vendor: 'anthropic', displayName: 'Anthropic', managementCommand: undefined, - when: undefined + when: undefined, + configuration: undefined }); languageModelsService.addModel('anthropic', 'anthropic-claude', { @@ -680,7 +712,8 @@ suite('ChatModelsViewModel', () => { vendor: 'azure', displayName: 'Azure OpenAI', managementCommand: undefined, - when: undefined + when: undefined, + configuration: undefined }); languageModelsService.addModel('azure', 'azure-gpt-4', { @@ -703,76 +736,63 @@ suite('ChatModelsViewModel', () => { await viewModel.refresh(); - const results = viewModel.filter(''); - const vendors = results.filter(isVendorEntry) as IVendorItemEntry[]; - - // Should have 4 vendors: copilot, openai, anthropic, azure + // Test with all filters and searches + results = viewModel.filter(''); + vendors = results.filter(isLanguageModelProviderEntry) as ILanguageModelProviderEntry[]; assert.strictEqual(vendors.length, 4); - - // First vendor should always be copilot - assert.strictEqual(vendors[0].vendorEntry.vendor, 'copilot'); - + assert.strictEqual(vendors[0].vendorEntry.vendor.vendor, 'copilot'); // Other vendors should be alphabetically sorted: anthropic, azure, openai - assert.strictEqual(vendors[1].vendorEntry.vendor, 'anthropic'); - assert.strictEqual(vendors[2].vendorEntry.vendor, 'azure'); - assert.strictEqual(vendors[3].vendorEntry.vendor, 'openai'); - }); - - test('should keep copilot at top even with text search', () => { - // Even when searching, if results include multiple vendors, copilot should be first - const results = viewModel.filter('GPT'); - - const vendors = results.filter(isVendorEntry) as IVendorItemEntry[]; + assert.strictEqual(vendors[1].vendorEntry.vendor.vendor, 'anthropic'); + assert.strictEqual(vendors[2].vendorEntry.vendor.vendor, 'azure'); + assert.strictEqual(vendors[3].vendorEntry.vendor.vendor, 'openai'); + // Test with text search + results = viewModel.filter('GPT'); + vendors = results.filter(isLanguageModelProviderEntry) as ILanguageModelProviderEntry[]; if (vendors.length > 1) { - // If multiple vendors match, copilot should be first - const copilotVendor = vendors.find(v => v.vendorEntry.vendor === 'copilot'); - if (copilotVendor) { - assert.strictEqual(vendors[0].vendorEntry.vendor, 'copilot'); - } + assert.strictEqual(vendors[0].vendorEntry.vendor.vendor, 'copilot'); } - }); - - test('should keep copilot at top when filtering by capability', () => { - const results = viewModel.filter('@capability:tools'); - const vendors = results.filter(isVendorEntry) as IVendorItemEntry[]; - - // Both copilot and openai have models with tools capability + // Test with capability filter + results = viewModel.filter('@capability:tools'); + vendors = results.filter(isLanguageModelProviderEntry) as ILanguageModelProviderEntry[]; if (vendors.length > 1) { - assert.strictEqual(vendors[0].vendorEntry.vendor, 'copilot'); + assert.strictEqual(vendors[0].vendorEntry.vendor.vendor, 'copilot'); } }); test('should show vendor headers when filtered', () => { const results = viewModel.filter('GPT'); - const vendors = results.filter(isVendorEntry); + const vendors = results.filter(isLanguageModelProviderEntry); 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); + const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(chatEntitlementService); await singleVendorViewModel.refresh(); const results = singleVendorViewModel.filter('GPT'); - const vendors = results.filter(isVendorEntry); + const vendors = results.filter(isLanguageModelProviderEntry); assert.strictEqual(vendors.length, 0); }); test('should group by visibility', () => { viewModel.groupBy = ChatModelGroup.Visibility; - const results = viewModel.filter(''); - - const groups = results.filter(isGroupEntry) as IGroupItemEntry[]; - assert.strictEqual(groups.length, 2); - assert.strictEqual(groups[0].group, 'visible'); - assert.strictEqual(groups[1].group, 'hidden'); - - const visibleModels = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r) && r.modelEntry.metadata.isUserSelectable) as IModelItemEntry[]; - const hiddenModels = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r) && !r.modelEntry.metadata.isUserSelectable) as IModelItemEntry[]; - - assert.strictEqual(visibleModels.length, 3); - assert.strictEqual(hiddenModels.length, 1); + const actuals = viewModel.viewModelEntries; + + assert.strictEqual(actuals.length, 6); + assert.strictEqual(actuals[0].type, 'group'); + assert.strictEqual(actuals[0].id, 'visible'); + assert.strictEqual(actuals[1].type, 'model'); + assert.strictEqual(actuals[1].model.identifier, 'copilot-gpt-4'); + assert.strictEqual(actuals[2].type, 'model'); + assert.strictEqual(actuals[2].model.identifier, 'copilot-gpt-4o'); + assert.strictEqual(actuals[3].type, 'model'); + assert.strictEqual(actuals[3].model.identifier, 'openai-gpt-3.5'); + assert.strictEqual(actuals[4].type, 'group'); + assert.strictEqual(actuals[4].id, 'hidden'); + assert.strictEqual(actuals[5].type, 'model'); + assert.strictEqual(actuals[5].model.identifier, 'openai-gpt-4-vision'); }); test('should fire onDidChangeGrouping when grouping changes', () => { @@ -786,13 +806,13 @@ suite('ChatModelsViewModel', () => { }); test('should reset collapsed state when grouping changes', () => { - const vendorEntry = viewModel.viewModelEntries.find(r => isVendorEntry(r) && r.vendorEntry.vendor === 'copilot') as IVendorItemEntry; + const vendorEntry = viewModel.viewModelEntries.find(r => isLanguageModelProviderEntry(r) && r.vendorEntry.vendor.vendor === 'copilot') as ILanguageModelProviderEntry; viewModel.toggleCollapsed(vendorEntry); viewModel.groupBy = ChatModelGroup.Visibility; const results = viewModel.filter(''); - const groups = results.filter(isGroupEntry) as IGroupItemEntry[]; + const groups = results.filter(isLanguageModelGroupEntry) as ILanguageModelGroupEntry[]; assert.ok(groups.every(v => !v.collapsed)); }); @@ -801,7 +821,8 @@ suite('ChatModelsViewModel', () => { vendor: 'anthropic', displayName: 'Anthropic', managementCommand: undefined, - when: undefined + when: undefined, + configuration: undefined }); languageModelsService.addModel('anthropic', 'anthropic-claude', { @@ -825,44 +846,111 @@ suite('ChatModelsViewModel', () => { await viewModel.refresh(); viewModel.groupBy = ChatModelGroup.Visibility; - const results = viewModel.filter(''); + const actuals = viewModel.viewModelEntries; + + assert.strictEqual(actuals.length, 7); + + assert.strictEqual(actuals[0].type, 'group'); + assert.strictEqual(actuals[0].id, 'visible'); + + assert.strictEqual(actuals[1].type, 'model'); + assert.strictEqual(actuals[1].model.metadata.id, 'gpt-4'); - const visibleModels = results.filter(r => !isVendorEntry(r) && !isGroupEntry(r) && r.modelEntry.metadata.isUserSelectable) as IModelItemEntry[]; + assert.strictEqual(actuals[2].type, 'model'); + assert.strictEqual(actuals[2].model.metadata.id, 'gpt-4o'); - assert.strictEqual(visibleModels.length, 4); - assert.strictEqual(visibleModels[0].modelEntry.metadata.name, 'GPT-4'); - assert.strictEqual(visibleModels[0].modelEntry.vendor, 'copilot'); + assert.strictEqual(actuals[3].type, 'model'); + assert.strictEqual(actuals[3].model.metadata.id, 'claude-3'); - assert.strictEqual(visibleModels[1].modelEntry.metadata.name, 'GPT-4o'); - assert.strictEqual(visibleModels[1].modelEntry.vendor, 'copilot'); + assert.strictEqual(actuals[4].type, 'model'); + assert.strictEqual(actuals[4].model.metadata.id, 'gpt-3.5-turbo'); - assert.strictEqual(visibleModels[2].modelEntry.metadata.name, 'Claude 3'); - assert.strictEqual(visibleModels[2].modelEntry.vendor, 'anthropic'); + assert.strictEqual(actuals[5].type, 'group'); + assert.strictEqual(actuals[5].id, 'hidden'); - assert.strictEqual(visibleModels[3].modelEntry.metadata.name, 'GPT-3.5 Turbo'); - assert.strictEqual(visibleModels[3].modelEntry.vendor, 'openai'); + assert.strictEqual(actuals[6].type, 'model'); + assert.strictEqual(actuals[6].model.metadata.id, 'gpt-4-vision'); }); - test('should not resort models when visibility is toggled', async () => { - viewModel.groupBy = ChatModelGroup.Visibility; + test('should get configured vendors', () => { + const vendors = viewModel.getConfiguredVendors(); + assert.ok(vendors.length > 0); + assert.ok(vendors.some(v => v.vendor.vendor === 'copilot')); + assert.ok(vendors.some(v => v.vendor.vendor === 'openai')); + }); + + test('should return true for shouldRefilter when models not sorted', () => { + // After a new filter call, models should be sorted + viewModel.filter(''); + assert.strictEqual(viewModel.shouldRefilter(), false); + + // Simulate unsorted state by accessing private property indirectly + // This is a simple test that shouldRefilter works + const result = viewModel.shouldRefilter(); + assert.strictEqual(typeof result, 'boolean'); + }); + + test('should collapse all groups and models', () => { + // Expand everything first + const results1 = viewModel.filter(''); + let models = results1.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; + assert.ok(models.length > 0); + + // Collapse all + viewModel.collapseAll(); - // Initial state: - // Visible: GPT-4, GPT-4o, GPT-3.5 Turbo - // Hidden: GPT-4 Vision + // After collapse all, only group/vendor headers should be shown + const results2 = viewModel.filter(''); + const vendors = results2.filter(isLanguageModelProviderEntry); + models = results2.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; - // Toggle GPT-4 Vision to visible - const hiddenModel = viewModel.viewModelEntries.find(r => !isVendorEntry(r) && !isGroupEntry(r) && r.modelEntry.identifier === 'openai-gpt-4-vision') as IModelItemEntry; - assert.ok(hiddenModel); - const initialIndex = viewModel.viewModelEntries.indexOf(hiddenModel); + assert.ok(vendors.length > 0, 'Should have vendor headers'); + assert.strictEqual(models.length, 0, 'Should have no models visible after collapse all'); + }); + + test('should match quoted search strings with filters', () => { + // Test that quotes don't break when combined with other filters + const results = viewModel.filter('@capability:tools "GPT"'); + assert.ok(Array.isArray(results)); + // Should handle without error + }); + + test('should filter by case-insensitive provider name', () => { + const results1 = viewModel.filter('@provider:COPILOT'); + const results2 = viewModel.filter('@provider:copilot'); + const results3 = viewModel.filter('@provider:CopiloT'); + + const models1 = results1.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; + const models2 = results2.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; + const models3 = results3.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; + + assert.strictEqual(models1.length, models2.length); + assert.strictEqual(models2.length, models3.length); + assert.strictEqual(models1.length, 2); + }); - viewModel.toggleVisibility(hiddenModel); + test('should handle empty search returning all results', () => { + const results = viewModel.filter(''); + assert.ok(results.length > 0); - // Verify it is still at the same index - const newIndex = viewModel.viewModelEntries.indexOf(hiddenModel); - assert.strictEqual(newIndex, initialIndex); + // Should include vendor headers and models + const vendors = results.filter(isLanguageModelProviderEntry); + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; + + assert.strictEqual(vendors.length, 2); + assert.strictEqual(models.length, 4); + }); + + test('should not find matches when searching for non-existent model', () => { + const results = viewModel.filter('NonExistentModel123'); + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; + assert.strictEqual(models.length, 0); + }); - // Verify metadata is updated - assert.strictEqual(hiddenModel.modelEntry.metadata.isUserSelectable, true); + test('should not find matches when filtering by non-existent provider', () => { + const results = viewModel.filter('@provider:nonexistent'); + const models = results.filter(r => !isLanguageModelProviderEntry(r) && !isLanguageModelGroupEntry(r)) as ILanguageModelEntry[]; + assert.strictEqual(models.length, 0); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/languageModelsConfiguration.test.ts b/src/vs/workbench/contrib/chat/test/browser/languageModelsConfiguration.test.ts new file mode 100644 index 0000000000000..0d1a184a8e4da --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/languageModelsConfiguration.test.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { parseLanguageModelsProviderGroups } from '../../browser/languageModelsConfigurationService.js'; + +suite('LanguageModelsConfiguration', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('parseLanguageModelsConfiguration - empty', () => { + const model = testDisposables.add(createTextModel('[]')); + const result = parseLanguageModelsProviderGroups(model); + assert.deepStrictEqual(result, []); + }); + + test('parseLanguageModelsConfiguration - simple', () => { + const content = JSON.stringify([{ + vendor: 'vendor', + name: 'group', + configurations: [] + }], null, '\t'); + const model = testDisposables.add(createTextModel(content)); + const result = parseLanguageModelsProviderGroups(model); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'group'); + assert.strictEqual(result[0].vendor, 'vendor'); + assert.ok(result[0].range); + }); + + test('parseLanguageModelsConfiguration - with configuration range', () => { + const content = `[ + { + "vendor": "vendor", + "name": "group", + "configurations": [ + { + "configuration": { + "foo": "bar" + } + } + ] + } +]`; + const model = testDisposables.add(createTextModel(content)); + const result = parseLanguageModelsProviderGroups(model); + + const configurations = result[0].configurations as { configuration: Record }[]; + const config = configurations[0].configuration; + assert.deepStrictEqual(config, { foo: 'bar' }); + }); + + test('parseLanguageModelsConfiguration - multiple vendors and groups', () => { + const content = `[ + { "vendor": "vendor1", "name": "g1", "configurations": [] }, + { "vendor": "vendor1", "name": "g2", "configurations": [] }, + { "vendor": "vendor2", "name": "g3", "configurations": [] } +]`; + const model = testDisposables.add(createTextModel(content)); + const result = parseLanguageModelsProviderGroups(model); + + assert.strictEqual(result.length, 3); + assert.strictEqual(result[0].name, 'g1'); + assert.strictEqual(result[0].vendor, 'vendor1'); + assert.strictEqual(result[1].name, 'g2'); + assert.strictEqual(result[1].vendor, 'vendor1'); + assert.strictEqual(result[2].name, 'g3'); + assert.strictEqual(result[2].vendor, 'vendor2'); + }); + + test('parseLanguageModelsConfiguration - complex configuration values', () => { + const content = `[ + { + "vendor": "vendor", + "name": "group", + "configurations": [ + { + "configuration": { + "str": "value", + "num": 123, + "bool": true, + "null": null, + "arr": [1, 2], + "obj": { "nested": "val" } + } + } + ] + } +]`; + const model = testDisposables.add(createTextModel(content)); + const result = parseLanguageModelsProviderGroups(model); + + const configurations = result[0]?.configurations as { configuration: Record }[]; + const config = configurations[0].configuration; + assert.strictEqual(config.str, 'value'); + assert.strictEqual(config.num, 123); + assert.strictEqual(config.bool, true); + assert.strictEqual(config.null, null); + assert.deepStrictEqual(config.arr, [1, 2]); + assert.deepStrictEqual(config.obj, { nested: 'val' }); + }); + + test('parseLanguageModelsConfiguration - with comments', () => { + const content = `[ + // This is a comment + /* Block comment */ + { + "vendor": "vendor", + "name": "group", + "configurations": [] + } +]`; + const model = testDisposables.add(createTextModel(content)); + const result = parseLanguageModelsProviderGroups(model); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'group'); + assert.strictEqual(result[0].vendor, 'vendor'); + }); + + test('parseLanguageModelsConfiguration - ranges', () => { + const content = `[ + { + "vendor": "vendor", + "name": "g1", + "configurations": [] + }, + { + "vendor": "vendor", + "name": "g2", + "configurations": [] + } +]`; + const model = testDisposables.add(createTextModel(content)); + const result = parseLanguageModelsProviderGroups(model); + + const g1 = result[0]; + const g2 = result[1]; + + assert.ok(g1.range); + assert.ok(g2.range); + assert.strictEqual(g1.range.startLineNumber, 2); + assert.strictEqual(g1.range.endLineNumber, 6); + assert.strictEqual(g2.range.startLineNumber, 7); + assert.strictEqual(g2.range.endLineNumber, 11); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 871281c33e13e..b0c19555dff9d 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -20,6 +20,9 @@ import { Event } from '../../../../../base/common/event.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; suite('LanguageModels', function () { @@ -41,7 +44,14 @@ suite('LanguageModels', function () { new TestStorageService(), new MockContextKeyService(), new TestConfigurationService(), - new TestChatEntitlementService() + new TestChatEntitlementService(), + new class extends mock() { + override getLanguageModelsProviderGroups() { + return []; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), ); const ext = ExtensionsRegistry.getExtensionPoints().find(e => e.name === languageModelChatProviderExtensionPoint.name)!; @@ -246,7 +256,10 @@ suite('LanguageModels - When Clause', function () { new TestStorageService(), contextKeyService, new TestConfigurationService(), - new TestChatEntitlementService() + new TestChatEntitlementService(), + new class extends mock() { }, + new class extends mock() { }, + new TestSecretStorageService(), ); const ext = ExtensionsRegistry.getExtensionPoints().find(e => e.name === languageModelChatProviderExtensionPoint.name)!; diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 3e2bf77220aa7..914a99f9cd2e8 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { IStringDictionary } from '../../../../../base/common/collections.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IChatMessage, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; +import { IChatMessage, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; export class NullLanguageModelsService implements ILanguageModelsService { _serviceBrand: undefined; @@ -46,6 +47,10 @@ export class NullLanguageModelsService implements ILanguageModelsService { return; } + async fetchLanguageModelGroups(vendor: string): Promise { + return []; + } + async selectLanguageModels(selector: ILanguageModelChatSelector): Promise { return []; } @@ -58,4 +63,12 @@ export class NullLanguageModelsService implements ILanguageModelsService { computeTokenLength(identifier: string, message: string | IChatMessage, token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + + async configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise { + + } + + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { + + } } diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index c4b8aadcf0505..1653bbdfc0175 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -78,6 +78,18 @@ declare module 'vscode' { export type LanguageModelResponsePart2 = LanguageModelResponsePart | LanguageModelDataPart | LanguageModelThinkingPart; export interface LanguageModelChatProvider { + provideLanguageModelChatInformation(options: PrepareLanguageModelChatModelOptions, token: CancellationToken): ProviderResult; provideLanguageModelChatResponse(model: T, messages: readonly LanguageModelChatRequestMessage[], options: ProvideLanguageModelChatResponseOptions, progress: Progress, token: CancellationToken): Thenable; } + + /** + * The list of options passed into {@linkcode LanguageModelChatProvider.provideLanguageModelChatInformation} + */ + export interface PrepareLanguageModelChatModelOptions { + /** + * Configuration for the model. This is only present if the provider has declared that it requires configuration via the `configuration` property. + * The object adheres to the schema that the extension provided during declaration. + */ + readonly configuration?: any; + } }