Path: blob/main/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type * as vscode from 'vscode';6import { filterMap } from '../../../util/common/arrays';7import { TaskQueue } from '../../../util/common/async';8import { ErrorUtils } from '../../../util/common/errors';9import { pushMany } from '../../../util/vs/base/common/arrays';10import { assertNever, softAssert } from '../../../util/vs/base/common/assert';11import { Emitter, Event } from '../../../util/vs/base/common/event';12import { Disposable } from '../../../util/vs/base/common/lifecycle';13import { derived, IObservable, observableFromEvent } from '../../../util/vs/base/common/observable';14import { CopilotToken } from '../../authentication/common/copilotToken';15import { ICopilotTokenStore } from '../../authentication/common/copilotTokenStore';16import { ConfigKey, ExperimentBasedConfig, IConfigurationService } from '../../configuration/common/configurationService';17import { IVSCodeExtensionContext } from '../../extContext/common/extensionContext';18import { ILogger, ILogService } from '../../log/common/logService';19import { IProxyModelsService } from '../../proxyModels/common/proxyModelsService';20import { IExperimentationService } from '../../telemetry/common/nullExperimentationService';21import { ITelemetryService } from '../../telemetry/common/telemetry';22import { WireTypes } from '../common/dataTypes/inlineEditsModelsTypes';23import { isPromptingStrategy, MODEL_CONFIGURATION_VALIDATOR, ModelConfiguration, PromptingStrategy } from '../common/dataTypes/xtabPromptOptions';24import { IInlineEditsModelService, IUndesiredModelsManager } from '../common/inlineEditsModelService';2526const enum ModelSource {27LocalConfig = 'localConfig',28ExpConfig = 'expConfig',29ExpDefaultConfig = 'expDefaultConfig',30Fetched = 'fetched',31HardCodedDefault = 'hardCodedDefault',32}3334interface ModelConfigurationWithSource extends ModelConfiguration {35source: ModelSource;36}3738type ModelInfo = {39models: ModelConfigurationWithSource[];40currentModelId: string;41};4243export class InlineEditsModelService extends Disposable implements IInlineEditsModelService {4445_serviceBrand: undefined;4647private static readonly COPILOT_NES_XTAB_MODEL: ModelConfigurationWithSource = {48modelName: 'copilot-nes-xtab',49promptingStrategy: PromptingStrategy.CopilotNesXtab,50includeTagsInCurrentFile: true,51source: ModelSource.HardCodedDefault,52lintOptions: undefined,53};5455private static readonly COPILOT_NES_OCT: ModelConfigurationWithSource = {56modelName: 'copilot-nes-oct',57promptingStrategy: PromptingStrategy.Xtab275,58includeTagsInCurrentFile: false,59source: ModelSource.HardCodedDefault,60lintOptions: undefined,61};6263private static readonly COPILOT_NES_CALLISTO: ModelConfigurationWithSource = {64modelName: 'nes-callisto',65promptingStrategy: PromptingStrategy.Xtab275,66includeTagsInCurrentFile: false,67source: ModelSource.HardCodedDefault,68lintOptions: undefined,69};7071private _copilotTokenObs = observableFromEvent(this, this._tokenStore.onDidStoreUpdate, () => this._tokenStore.copilotToken);7273// TODO@ulugbekna: use a derived observable such that it fires only when nesModels change74private _fetchedModelsObs = observableFromEvent(this, this._proxyModelsService.onModelListUpdated, () => this._proxyModelsService.nesModels);7576private _preferredModelNameObs = this._configService.getExperimentBasedConfigObservable(ConfigKey.Advanced.InlineEditsPreferredModel, this._expService);77private _localModelConfigObs = this._configService.getConfigObservable(ConfigKey.Advanced.InlineEditsXtabProviderModelConfiguration);78private _expBasedModelConfigObs = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsXtabProviderModelConfigurationString, this._expService);79private _defaultModelConfigObs = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsXtabProviderDefaultModelConfigurationString, this._expService);80private _useSlashModelsObs = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsUseSlashModels, this._expService);81private _undesiredModelsObs = observableFromEvent(this, this._undesiredModelsManager.onDidChange, () => this._undesiredModelsManager);8283private _modelsObs: IObservable<ModelConfigurationWithSource[]>;84private _currentModelObs: IObservable<ModelConfigurationWithSource>;85private _modelInfoObs: IObservable<ModelInfo>;8687public readonly onModelListUpdated: Event<void>;8889private readonly _setModelQueue = new TaskQueue();90private _logger: ILogger;9192constructor(93@ICopilotTokenStore private readonly _tokenStore: ICopilotTokenStore,94@IProxyModelsService private readonly _proxyModelsService: IProxyModelsService,95@IUndesiredModelsManager private readonly _undesiredModelsManager: IUndesiredModelsManager,96@IConfigurationService private readonly _configService: IConfigurationService,97@IExperimentationService private readonly _expService: IExperimentationService,98@ITelemetryService private readonly _telemetryService: ITelemetryService,99@ILogService private readonly _logService: ILogService,100) {101super();102103this._logger = _logService.createSubLogger(['NES', 'ModelsService']);104105const logger = this._logger.createSubLogger('constructor');106107this._modelsObs = derived((reader) => {108logger.trace('computing models');109return this.aggregateModels({110copilotToken: this._copilotTokenObs.read(reader),111fetchedNesModels: this._fetchedModelsObs.read(reader),112localModelConfig: this._localModelConfigObs.read(reader),113modelConfigString: this._expBasedModelConfigObs.read(reader),114defaultModelConfigString: this._defaultModelConfigObs.read(reader),115useSlashModels: this._useSlashModelsObs.read(reader),116});117}).recomputeInitiallyAndOnChange(this._store);118119this._currentModelObs = derived<ModelConfigurationWithSource, void>((reader) => {120logger.trace('computing current model');121const undesiredModelsManager = this._undesiredModelsObs.read(reader);122return this._pickModel({123preferredModelName: this._preferredModelNameObs.read(reader),124models: this._modelsObs.read(reader),125undesiredModelsManager,126});127}).recomputeInitiallyAndOnChange(this._store);128129this._modelInfoObs = derived((reader) => {130logger.trace('computing model info');131return {132models: this._modelsObs.read(reader),133currentModelId: this._currentModelObs.read(reader).modelName,134};135}).recomputeInitiallyAndOnChange(this._store);136137this.onModelListUpdated = Event.fromObservableLight(this._modelInfoObs);138}139140get modelInfo(): vscode.InlineCompletionModelInfo | undefined {141const models: vscode.InlineCompletionModel[] = this._modelsObs.get().map(m => ({142id: m.modelName,143name: m.modelName,144}));145146const currentModel = this._currentModelObs.get();147148return {149models,150currentModelId: currentModel.modelName,151};152}153154155setCurrentModelId(newPreferredModelId: string): Promise<void> {156return this._setModelQueue.schedule(() => this._setCurrentModelIdCore(newPreferredModelId));157}158159private async _setCurrentModelIdCore(newPreferredModelId: string): Promise<void> {160const currentPreferredModelId = this._configService.getExperimentBasedConfig(ConfigKey.Advanced.InlineEditsPreferredModel, this._expService);161162const isSameModel = currentPreferredModelId === newPreferredModelId;163if (isSameModel) {164return;165}166167// snapshot before async calls168const currentPreferredModel = this._currentModelObs.get();169170const models = this._modelsObs.get();171const newPreferredModel = models.find(m => m.modelName === newPreferredModelId);172173if (newPreferredModel === undefined) {174this._logService.error(`New preferred model id ${newPreferredModelId} not found in model list.`);175return;176}177178// if currently selected model is from exp config, then mark that model as undesired179if (currentPreferredModel.source === ModelSource.ExpConfig) {180await this._undesiredModelsManager.addUndesiredModelId(currentPreferredModel.modelName);181}182183if (this._undesiredModelsManager.isUndesiredModelId(newPreferredModelId)) {184await this._undesiredModelsManager.removeUndesiredModelId(newPreferredModelId);185}186187// if user picks same as the default model, we should reset the user setting188// otherwise, update the model189const expectedDefaultModel = this._pickModel({ preferredModelName: 'none', models, undesiredModelsManager: this._undesiredModelsManager });190if (newPreferredModel.source === ModelSource.ExpConfig || // because exp-configured model already takes highest priority191(newPreferredModelId === expectedDefaultModel.modelName && !models.some(m => m.source === ModelSource.ExpConfig))192) {193this._logger.trace(`New preferred model id ${newPreferredModelId} is the same as the default model, resetting user setting.`);194await this._configService.setConfig(ConfigKey.Advanced.InlineEditsPreferredModel, 'none');195} else {196this._logger.trace(`New preferred model id ${newPreferredModelId} is different from the default model, updating user setting to ${newPreferredModelId}.`);197await this._configService.setConfig(ConfigKey.Advanced.InlineEditsPreferredModel, newPreferredModelId);198}199}200201private aggregateModels(202{203copilotToken,204fetchedNesModels,205localModelConfig,206modelConfigString,207defaultModelConfigString,208useSlashModels,209}: {210copilotToken: CopilotToken | undefined;211fetchedNesModels: WireTypes.Model.t[] | undefined;212localModelConfig: ModelConfiguration | null;213modelConfigString: string | undefined;214defaultModelConfigString: string | undefined;215useSlashModels: boolean;216},217): ModelConfigurationWithSource[] {218const logger = this._logger.createSubLogger('aggregateModels');219220const models: ModelConfigurationWithSource[] = [];221222// priority of adding models to the list:223// 0. model from user local setting224// 1. model from modelConfigurationString setting (set through ExP)225// 2. fetched models from /models endpoint (if useSlashModels is true)226227if (localModelConfig) {228if (models.some(m => m.modelName === localModelConfig.modelName)) {229logger.trace('Local model configuration already exists in the model list, skipping.');230} else {231logger.trace(`Adding local model configuration: ${localModelConfig.modelName}`);232models.push({ ...localModelConfig, source: ModelSource.LocalConfig });233}234}235236if (modelConfigString) {237logger.trace('Parsing modelConfigurationString...');238const parsedConfig = this.parseModelConfigString(modelConfigString, ConfigKey.TeamInternal.InlineEditsXtabProviderModelConfigurationString);239if (parsedConfig && !models.some(m => m.modelName === parsedConfig.modelName)) {240logger.trace(`Adding model from modelConfigurationString: ${parsedConfig.modelName}`);241models.push({ ...parsedConfig, source: ModelSource.ExpConfig });242} else {243logger.trace('No valid model found in modelConfigurationString.');244}245}246247if (useSlashModels && fetchedNesModels && fetchedNesModels.length > 0) {248logger.trace(`Processing ${fetchedNesModels.length} fetched models...`);249const filteredFetchedModels = filterMap(fetchedNesModels, (m) => {250if (!isPromptingStrategy(m.capabilities.promptStrategy)) {251return undefined;252}253if (models.some(knownModel => knownModel.modelName === m.name)) {254logger.trace(`Fetched model ${m.name} already exists in the model list, skipping.`);255return undefined;256}257return {258modelName: m.name,259promptingStrategy: m.capabilities.promptStrategy,260includeTagsInCurrentFile: false, // FIXME@ulugbekna: determine this based on model capabilities and config261source: ModelSource.Fetched,262lintOptions: undefined,263} satisfies ModelConfigurationWithSource;264});265logger.trace(`Adding ${filteredFetchedModels.length} fetched models after filtering.`);266pushMany(models, filteredFetchedModels);267} else {268// push default model if /models doesn't give us any models269logger.trace(`adding built-in default model: useSlashModels ${useSlashModels}, fetchedNesModels ${fetchedNesModels?.length ?? 'undefined'}`);270271const defaultModel = this.determineDefaultModel(copilotToken, defaultModelConfigString);272if (defaultModel) {273if (models.some(m => m.modelName === defaultModel.modelName)) {274logger.trace('Default model configuration already exists in the model list, skipping.');275} else {276logger.trace(`Adding default model configuration: ${defaultModel.modelName}`);277models.push(defaultModel);278}279}280}281282return models;283}284285public selectedModelConfiguration(): ModelConfiguration {286return toModelConfiguration(this._currentModelObs.get());287}288289public defaultModelConfiguration(): ModelConfiguration {290const models = this._modelsObs.get();291if (models && models.length > 0) {292const defaultModels = models.filter(m => !this.isConfiguredModel(m));293if (defaultModels.length > 0) {294return toModelConfiguration(defaultModels[0]);295}296}297return toModelConfiguration(this.determineDefaultModel(this._copilotTokenObs.get(), this._defaultModelConfigObs.get()));298}299300private isConfiguredModel(model: ModelConfigurationWithSource): boolean {301switch (model.source) {302case ModelSource.LocalConfig:303case ModelSource.ExpConfig:304case ModelSource.ExpDefaultConfig:305return true;306case ModelSource.Fetched:307case ModelSource.HardCodedDefault:308return false;309default:310assertNever(model.source);311}312}313314private determineDefaultModel(copilotToken: CopilotToken | undefined, defaultModelConfigString: string | undefined): ModelConfigurationWithSource {315// if a default model config string is specified, use that316if (defaultModelConfigString) {317const parsedConfig = this.parseModelConfigString(defaultModelConfigString, ConfigKey.TeamInternal.InlineEditsXtabProviderDefaultModelConfigurationString);318if (parsedConfig) {319return { ...parsedConfig, source: ModelSource.ExpDefaultConfig };320}321}322323// otherwise, use built-in defaults324if (copilotToken?.isFcv1()) {325return InlineEditsModelService.COPILOT_NES_XTAB_MODEL;326} else if (copilotToken?.isFreeUser || copilotToken?.isNoAuthUser) {327return InlineEditsModelService.COPILOT_NES_CALLISTO;328} else {329return InlineEditsModelService.COPILOT_NES_OCT;330}331}332333private _pickModel({334preferredModelName,335models,336undesiredModelsManager,337}: {338preferredModelName: string;339models: ModelConfigurationWithSource[];340undesiredModelsManager: IUndesiredModelsManager;341}): ModelConfigurationWithSource {342// priority of picking a model:343// 0. model from modelConfigurationString setting from ExP, unless marked as undesired344// 1. user preferred model345// 2. first model in the list346347const expConfiguredModel = models.find(m => m.source === ModelSource.ExpConfig);348if (expConfiguredModel) {349const isUndesiredModelId = undesiredModelsManager.isUndesiredModelId(expConfiguredModel.modelName);350if (isUndesiredModelId) {351this._logger.trace(`Exp-configured model ${expConfiguredModel.modelName} is marked as undesired by the user. Skipping.`);352} else {353return expConfiguredModel;354}355}356357const userHasPreferredModel = preferredModelName !== 'none';358359if (userHasPreferredModel) {360const preferredModel = models.find(m => m.modelName === preferredModelName);361if (preferredModel) {362return preferredModel;363}364}365366softAssert(models.length > 0, 'InlineEdits model list should have at least one model');367368const model = models.at(0);369if (model) {370return model;371}372373return this.determineDefaultModel(this._copilotTokenObs.get(), this._defaultModelConfigObs.get());374}375376private parseModelConfigString(configString: string, configKey: ExperimentBasedConfig<string | undefined>): ModelConfiguration | undefined {377let errorMessage: string;378try {379const parsed: unknown = JSON.parse(configString);380const result = MODEL_CONFIGURATION_VALIDATOR.validate(parsed);381if (!result.error) {382return result.content;383}384errorMessage = result.error.message;385} catch (e: unknown) {386errorMessage = ErrorUtils.toString(ErrorUtils.fromUnknown(e));387}388389/* __GDPR__390"incorrectNesModelConfig" : {391"owner": "ulugbekna",392"comment": "Capture if model configuration string is invalid or malformed.",393"configName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Name of the configuration that failed to parse." },394"errorMessage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error message from parsing or validation." },395"configValue": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The invalid config string." }396}397*/398this._telemetryService.sendMSFTTelemetryEvent('incorrectNesModelConfig', { configName: configKey.id, errorMessage, configValue: configString });399return undefined;400}401}402403function toModelConfiguration(model: ModelConfigurationWithSource): ModelConfiguration {404const { source: _, ...config } = model;405return config;406}407408export namespace UndesiredModels {409410const UNDESIRED_MODELS_KEY = 'copilot.chat.nextEdits.undesiredModelIds';411type UndesiredModelsValue = string[];412413export class Manager extends Disposable implements IUndesiredModelsManager {414declare _serviceBrand: undefined;415416private readonly _onDidChange = this._register(new Emitter<void>());417readonly onDidChange = this._onDidChange.event;418419private readonly _queue = new TaskQueue();420421constructor(422@IVSCodeExtensionContext private readonly _vscodeExtensionContext: IVSCodeExtensionContext,423) {424super();425}426427isUndesiredModelId(modelId: string) {428const models = this._getModels();429return models.includes(modelId);430}431432addUndesiredModelId(modelId: string): Promise<void> {433return this._queue.schedule(async () => {434const models = this._getModels();435if (!models.includes(modelId)) {436models.push(modelId);437await this._setModels(models);438this._onDidChange.fire();439}440});441}442443removeUndesiredModelId(modelId: string): Promise<void> {444return this._queue.schedule(async () => {445const models = this._getModels();446const index = models.indexOf(modelId);447if (index !== -1) {448models.splice(index, 1);449await this._setModels(models);450this._onDidChange.fire();451}452});453}454455private _getModels(): string[] {456return this._vscodeExtensionContext.globalState.get<UndesiredModelsValue>(UNDESIRED_MODELS_KEY) ?? [];457}458459private _setModels(models: string[]): Promise<void> {460return new Promise((resolve, reject) => {461this._vscodeExtensionContext.globalState.update(UNDESIRED_MODELS_KEY, models).then(resolve, reject);462});463}464}465}466467468469