Initial refactoring of Language Models Access Management (#251773)

To clean things up... this still has the association that you must get access to language models provided by a particular extension.
This commit is contained in:
Tyler James Leonhardt 2025-06-17 22:46:46 -07:00 committed by GitHub
parent 0140ab3f1b
commit 66cc872a0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 267 additions and 19 deletions

View File

@ -515,6 +515,7 @@ export class QuickInputController extends Disposable {
input.ignoreFocusOut = !!options.ignoreFocusLost;
input.matchOnDescription = !!options.matchOnDescription;
input.matchOnDetail = !!options.matchOnDetail;
input.sortByLabel = !!options.sortByLabel;
input.matchOnLabel = (options.matchOnLabel === undefined) || options.matchOnLabel; // default to true
input.quickNavigate = options.quickNavigate;
input.hideInput = !!options.hideInput;

View File

@ -195,6 +195,7 @@ class QuickPickItemElement extends BaseQuickPickItemElement {
constructor(
index: number,
readonly childIndex: number,
hasCheckbox: boolean,
readonly fireButtonTriggered: (event: IQuickPickItemButtonEvent<IQuickPickItem>) => void,
private _onChecked: Emitter<{ element: IQuickPickElement; checked: boolean }>,
@ -520,7 +521,7 @@ class QuickPickItemElementRenderer extends BaseQuickInputListRenderer<QuickPickI
} else {
data.separator.style.display = 'none';
}
data.entry.classList.toggle('quick-input-list-separator-border', !!element.separator);
data.entry.classList.toggle('quick-input-list-separator-border', !!element.separator && element.childIndex !== 0);
// Actions
const buttons = mainItem.buttons;
@ -1098,12 +1099,13 @@ export class QuickInputTree extends Disposable {
const previous = index > 0 ? inputElements[index - 1] : undefined;
let separator: IQuickPickSeparator | undefined;
if (previous && previous.type === 'separator' && !previous.buttons) {
// Found an inline separator so we clear out the current separator element
currentSeparatorElement = undefined;
separator = previous;
}
const qpi = new QuickPickItemElement(
index,
currentSeparatorElement?.children
? currentSeparatorElement.children.length
: index,
this._hasCheckboxes && item.pickable !== false,
e => this._onButtonTriggered.fire(e),
this._elementChecked,

View File

@ -109,6 +109,11 @@ export interface IPickOptions<T extends IQuickPickItem> {
*/
matchOnLabel?: boolean;
/**
* an optional flag to sort the picks based by the label.
*/
sortByLabel?: boolean;
/**
* an optional flag to not close the picker on focus lost
*/

View File

@ -6,7 +6,7 @@
import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js';
import * as nls from '../../../nls.js';
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, INTERNAL_AUTH_PROVIDER_PREFIX as INTERNAL_MODEL_AUTH_PROVIDER_PREFIX, AuthenticationSessionAccount, IAuthenticationProviderSessionOptions } from '../../services/authentication/common/authentication.js';
import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, AuthenticationSessionAccount, IAuthenticationProviderSessionOptions } from '../../services/authentication/common/authentication.js';
import { ExtHostAuthenticationShape, ExtHostContext, MainContext, MainThreadAuthenticationShape } from '../common/extHost.protocol.js';
import { IDialogService, IPromptButton } from '../../../platform/dialogs/common/dialogs.js';
import Severity from '../../../base/common/severity.js';
@ -236,9 +236,10 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
private async loginPrompt(provider: IAuthenticationProvider, extensionName: string, recreatingSession: boolean, options?: AuthenticationInteractiveOptions): Promise<boolean> {
let message: string;
// An internal provider is a special case which is for model access only.
if (provider.id.startsWith(INTERNAL_MODEL_AUTH_PROVIDER_PREFIX)) {
message = nls.localize('confirmModelAccess', "The extension '{0}' wants to access the language models provided by {1}.", extensionName, provider.label);
// Check if the provider has a custom confirmation message
const customMessage = provider.confirmation?.(extensionName, recreatingSession);
if (customMessage) {
message = customMessage;
} else {
message = recreatingSession
? nls.localize('confirmRelogin', "The extension '{0}' wants you to sign in again using {1}.", extensionName, provider.label)

View File

@ -258,6 +258,10 @@ class LanguageModelAccessAuthProvider implements IAuthenticationProvider {
return Promise.resolve();
}
confirmation(extensionName: string, _recreatingSession: boolean): string {
return localize('confirmLanguageModelAccess', "The extension '{0}' wants to access the language models provided by {1}.", extensionName, this.label);
}
private _createFakeSession(scopes: string[]): AuthenticationSession {
return {
id: 'fake-session',

View File

@ -33,7 +33,7 @@ import { ILogService } from '../../../platform/log/common/log.js';
import { IProductService } from '../../../platform/product/common/productService.js';
import { ISecretStorageService } from '../../../platform/secrets/common/secrets.js';
import { AuthenticationSessionInfo, getCurrentAuthenticationSessionInfo } from '../../services/authentication/browser/authenticationService.js';
import { AuthenticationSessionAccount, IAuthenticationService } from '../../services/authentication/common/authentication.js';
import { AuthenticationSessionAccount, IAuthenticationService, INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js';
import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js';
import { IHoverService } from '../../../platform/hover/browser/hover.js';
import { ILifecycleService, LifecyclePhase } from '../../services/lifecycle/common/lifecycle.js';
@ -363,16 +363,19 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction
let menus: IAction[] = [];
for (const providerId of providers) {
if (providerId.startsWith(INTERNAL_AUTH_PROVIDER_PREFIX)) {
continue;
}
if (!this.initialized) {
const noAccountsAvailableAction = disposables.add(new Action('noAccountsAvailable', localize('loading', "Loading..."), undefined, false));
menus.push(noAccountsAvailableAction);
break;
}
const providerLabel = this.authenticationService.getProvider(providerId).label;
const provider = this.authenticationService.getProvider(providerId);
const accounts = this.groupedAccounts.get(providerId);
if (!accounts) {
if (this.problematicProviders.has(providerId)) {
const providerUnavailableAction = disposables.add(new Action('providerUnavailable', localize('authProviderUnavailable', '{0} is currently unavailable', providerLabel), undefined, false));
const providerUnavailableAction = disposables.add(new Action('providerUnavailable', localize('authProviderUnavailable', '{0} is currently unavailable', provider.label), undefined, false));
menus.push(providerUnavailableAction);
// try again in the background so that if the failure was intermittent, we can resolve it on the next showing of the menu
try {
@ -384,6 +387,7 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction
continue;
}
const canUseMcp = !!provider.authorizationServers?.length;
for (const account of accounts) {
const manageExtensionsAction = toAction({
id: `configureSessions${account.label}`,
@ -392,15 +396,17 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction
run: () => this.commandService.executeCommand('_manageTrustedExtensionsForAccount', { providerId, accountLabel: account.label })
});
const manageMCPAction = toAction({
id: `configureSessions${account.label}`,
label: localize('manageTrustedMCPServers', "Manage Trusted MCP Servers"),
enabled: true,
run: () => this.commandService.executeCommand('_manageTrustedMCPServersForAccount', { providerId, accountLabel: account.label })
});
const providerSubMenuActions: IAction[] = [manageExtensionsAction, manageMCPAction];
const providerSubMenuActions: IAction[] = [manageExtensionsAction];
if (canUseMcp) {
const manageMCPAction = toAction({
id: `configureSessions${account.label}`,
label: localize('manageTrustedMCPServers', "Manage Trusted MCP Servers"),
enabled: true,
run: () => this.commandService.executeCommand('_manageTrustedMCPServersForAccount', { providerId, accountLabel: account.label })
});
providerSubMenuActions.push(manageMCPAction);
}
if (account.canSignOut) {
providerSubMenuActions.push(toAction({
id: 'signOut',
@ -410,7 +416,7 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction
}));
}
const providerSubMenu = new SubmenuAction('activitybar.submenu', `${account.label} (${providerLabel})`, providerSubMenuActions);
const providerSubMenu = new SubmenuAction('activitybar.submenu', `${account.label} (${provider.label})`, providerSubMenuActions);
menus.push(providerSubMenu);
}
}

View File

@ -0,0 +1,218 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../../platform/quickinput/common/quickInput.js';
import { ILanguageModelsService } from '../../common/languageModels.js';
import { IAuthenticationAccessService } from '../../../../services/authentication/browser/authenticationAccessService.js';
import { localize, localize2 } from '../../../../../nls.js';
import { AllowedExtension, INTERNAL_AUTH_PROVIDER_PREFIX } from '../../../../services/authentication/common/authentication.js';
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
import { CHAT_CATEGORY } from './chatActions.js';
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
class ManageLanguageModelAuthenticationAction extends Action2 {
static readonly ID = 'workbench.action.chat.manageLanguageModelAuthentication';
constructor() {
super({
id: ManageLanguageModelAuthenticationAction.ID,
title: localize2('manageLanguageModelAuthentication', 'Manage Language Model Access...'),
category: CHAT_CATEGORY,
menu: [{
id: MenuId.AccountsContext,
order: 100,
}],
f1: true
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const quickInputService = accessor.get(IQuickInputService);
const languageModelsService = accessor.get(ILanguageModelsService);
const authenticationAccessService = accessor.get(IAuthenticationAccessService);
const dialogService = accessor.get(IDialogService);
const extensionService = accessor.get(IExtensionService);
const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService);
const productService = accessor.get(IProductService);
// Get all registered language models
const modelIds = languageModelsService.getLanguageModelIds();
// Group models by owning extension and collect all allowed extensions
const extensionAuth = new Map<string, AllowedExtension[]>();
const ownerToAccountLabel = new Map<string, string>();
for (const modelId of modelIds) {
const model = languageModelsService.lookupLanguageModel(modelId);
if (!model?.auth) {
continue; // Skip if model is not found
}
const ownerId = model.extension.value;
if (extensionAuth.has(ownerId)) {
// If the owner already exists, just continue
continue;
}
// Get allowed extensions for this model's auth provider
try {
// Use providerLabel as the providerId and accountLabel (or default)
const providerId = INTERNAL_AUTH_PROVIDER_PREFIX + ownerId;
const accountLabel = model.auth.accountLabel || 'Language Models';
ownerToAccountLabel.set(ownerId, accountLabel);
const allowedExtensions = authenticationAccessService.readAllowedExtensions(
providerId,
accountLabel
).filter(ext => !ext.trusted); // Filter out trusted extensions because those should not be modified
if (productService.trustedExtensionAuthAccess && !Array.isArray(productService.trustedExtensionAuthAccess)) {
const trustedExtensions = productService.trustedExtensionAuthAccess[providerId];
// If the provider is trusted, add all trusted extensions to the allowed list
for (const ext of trustedExtensions) {
const index = allowedExtensions.findIndex(a => a.id === ext);
if (index !== -1) {
allowedExtensions.splice(index, 1);
}
const extension = await extensionService.getExtension(ext);
if (!extension) {
continue; // Skip if the extension is not found
}
allowedExtensions.push({
id: ext,
name: extension.displayName || extension.name,
allowed: true, // Assume trusted extensions are allowed by default
trusted: true // Mark as trusted
});
}
}
// Only grab extensions that are gettable from the extension service
const filteredExtensions = new Array<AllowedExtension>();
for (const ext of allowedExtensions) {
if (await extensionService.getExtension(ext.id)) {
filteredExtensions.push(ext);
}
}
extensionAuth.set(ownerId, filteredExtensions);
// Add all allowed extensions to the set for this owner
} catch (error) {
// Handle error by ensuring the owner is in the map
if (!extensionAuth.has(ownerId)) {
extensionAuth.set(ownerId, []);
}
}
}
if (extensionAuth.size === 0) {
dialogService.prompt({
type: 'info',
message: localize('noLanguageModels', 'No language models requiring authentication found.'),
detail: localize('noLanguageModelsDetail', 'There are currently no language models that require authentication.')
});
return;
}
const items: QuickPickInput<IQuickPickItem & { extension?: AllowedExtension; ownerId?: string }>[] = [];
// Create QuickPick items grouped by owner extension
for (const [ownerId, allowedExtensions] of extensionAuth) {
const extension = await extensionService.getExtension(ownerId);
if (!extension) {
// If the extension is not found, skip it
continue;
}
// Add separator for the owning extension
items.push({
type: 'separator',
id: ownerId,
label: localize('extensionOwner', '{0}', extension.displayName || extension.name),
buttons: [{
iconClass: ThemeIcon.asClassName(Codicon.info),
tooltip: localize('openExtension', 'Open Extension'),
}]
});
// Add allowed extensions as checkboxes (visual representation)
let addedTrustedSeparator = false;
if (allowedExtensions.length > 0) {
for (const allowedExt of allowedExtensions) {
if (allowedExt.trusted && !addedTrustedSeparator) {
items.push({
type: 'separator',
label: localize('trustedExtension', 'Trusted by Microsoft'),
});
addedTrustedSeparator = true;
}
items.push({
label: allowedExt.name,
ownerId,
id: allowedExt.id,
picked: allowedExt.allowed ?? false,
extension: allowedExt,
disabled: allowedExt.trusted, // Don't allow toggling trusted extensions
});
}
} else {
items.push({
label: localize('noAllowedExtensions', 'No extensions have access'),
description: localize('noAccessDescription', 'No extensions are currently allowed to use models from {0}', ownerId),
pickable: false
});
}
}
// Show the QuickPick
const result = await quickInputService.pick(
items,
{
canPickMany: true,
sortByLabel: true,
onDidTriggerSeparatorButton(context) {
// Handle separator button clicks
const extId = context.separator.id!;
if (extId) {
// Open the extension in the editor
void extensionsWorkbenchService.open(extId);
}
},
title: localize('languageModelAuthTitle', 'Manage Language Model Access'),
placeHolder: localize('languageModelAuthPlaceholder', 'Choose which extensions can access language models'),
}
);
if (!result) {
return;
}
for (const [ownerId, allowedExtensions] of extensionAuth) {
// diff with result to find out which extensions are allowed or not
// but we need to only look at the result items that have the ownerId
const allowedSet = new Set(result
.filter(item => item.ownerId === ownerId)
// only save items that are not trusted automatically
.filter(item => !item.extension?.trusted)
.map(item => item.id!));
for (const allowedExt of allowedExtensions) {
allowedExt.allowed = allowedSet.has(allowedExt.id);
}
authenticationAccessService.updateAllowedExtensions(
INTERNAL_AUTH_PROVIDER_PREFIX + ownerId,
ownerToAccountLabel.get(ownerId) || 'Language Models',
allowedExtensions
);
}
}
}
export function registerLanguageModelActions() {
registerAction2(ManageLanguageModelAuthenticationAction);
}

View File

@ -112,6 +112,7 @@ import { ChatResponseResourceFileSystemProvider } from '../common/chatResponseRe
import { runSaveToPromptAction, SAVE_TO_PROMPT_SLASH_COMMAND_NAME } from './promptSyntax/saveToPromptAction.js';
import { ChatDynamicVariableModel } from './contrib/chatDynamicVariables.js';
import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './chatAttachmentResolveService.js';
import { registerLanguageModelActions } from './actions/chatLanguageModelActions.js';
// Register configuration
const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);
@ -736,6 +737,7 @@ registerChatContextActions();
registerChatDeveloperActions();
registerChatEditorActions();
registerChatToolActions();
registerLanguageModelActions();
registerEditorFeature(ChatPasteProvidersFeature);

View File

@ -361,6 +361,15 @@ export interface IAuthenticationProvider {
*/
readonly supportsMultipleAccounts: boolean;
/**
* Optional function to provide a custom confirmation message for authentication prompts.
* If not implemented, the default confirmation messages will be used.
* @param extensionName - The name of the extension requesting authentication.
* @param recreatingSession - Whether this is recreating an existing session.
* @returns A custom confirmation message or undefined to use the default message.
*/
readonly confirmation?: (extensionName: string, recreatingSession: boolean) => string | undefined;
/**
* An {@link Event} which fires when the array of sessions has changed, or data
* within a session has changed.