Path: blob/main/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts
3296 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 product from '../../../../platform/product/common/product.js';6import { Barrier } from '../../../../base/common/async.js';7import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';8import { Emitter, Event } from '../../../../base/common/event.js';9import { Lazy } from '../../../../base/common/lazy.js';10import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';11import { IRequestContext } from '../../../../base/parts/request/common/request.js';12import { localize } from '../../../../nls.js';13import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';14import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';15import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';16import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';17import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';18import { ILogService } from '../../../../platform/log/common/log.js';19import { IProductService } from '../../../../platform/product/common/productService.js';20import { asText, IRequestService } from '../../../../platform/request/common/request.js';21import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';22import { ITelemetryService, TelemetryLevel } from '../../../../platform/telemetry/common/telemetry.js';23import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../../services/authentication/common/authentication.js';24import { EnablementState, IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js';25import { IExtension, IExtensionsWorkbenchService } from '../../extensions/common/extensions.js';26import { ChatContextKeys } from './chatContextKeys.js';27import { IOpenerService } from '../../../../platform/opener/common/opener.js';28import { URI } from '../../../../base/common/uri.js';29import Severity from '../../../../base/common/severity.js';30import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';31import { isWeb } from '../../../../base/common/platform.js';32import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js';33import { Mutable } from '../../../../base/common/types.js';34import { distinct } from '../../../../base/common/arrays.js';3536export const IChatEntitlementService = createDecorator<IChatEntitlementService>('chatEntitlementService');3738export enum ChatEntitlement {39/** Signed out */40Unknown = 1,41/** Signed in but not yet resolved */42Unresolved,43/** Signed in and entitled to Free */44Available,45/** Signed in but not entitled to Free */46Unavailable,47/** Signed-up to Free */48Free,49/** Signed-up to Pro */50Pro,51/** Signed-up to Pro Plus */52ProPlus,53/** Signed-up to Business */54Business,55/** Signed-up to Enterprise */56Enterprise57}5859export interface IChatSentiment {6061/**62* User has Chat installed.63*/64installed?: boolean;6566/**67* User signals no intent in using Chat.68*69* Note: in contrast to `disabled`, this should not only disable70* Chat but also hide all of its UI.71*/72hidden?: boolean;7374/**75* User signals intent to disable Chat.76*77* Note: in contrast to `hidden`, this should not hide78* Chat but but disable its functionality.79*/80disabled?: boolean;8182/**83* Chat is disabled due to missing workspace trust.84*85* Note: even though this disables Chat, we want to treat it86* different from the `disabled` state that is by explicit87* user choice.88*/89untrusted?: boolean;9091/**92* User signals intent to use Chat later.93*/94later?: boolean;95}9697export interface IChatEntitlementService {9899_serviceBrand: undefined;100101readonly onDidChangeEntitlement: Event<void>;102103readonly entitlement: ChatEntitlement;104105readonly organisations: string[] | undefined;106readonly isInternal: boolean;107readonly sku: string | undefined;108109readonly onDidChangeQuotaExceeded: Event<void>;110readonly onDidChangeQuotaRemaining: Event<void>;111112readonly quotas: IQuotas;113114update(token: CancellationToken): Promise<void>;115116readonly onDidChangeSentiment: Event<void>;117118readonly sentiment: IChatSentiment;119}120121//#region Helper Functions122123/**124* Checks the chat entitlements to see if the user falls into the paid category125* @param chatEntitlement The chat entitlement to check126* @returns Whether or not they are a paid user127*/128export function isProUser(chatEntitlement: ChatEntitlement): boolean {129return chatEntitlement === ChatEntitlement.Pro ||130chatEntitlement === ChatEntitlement.ProPlus ||131chatEntitlement === ChatEntitlement.Business ||132chatEntitlement === ChatEntitlement.Enterprise;133}134135//#region Service Implementation136137const defaultChat = {138extensionId: product.defaultChatAgent?.extensionId ?? '',139chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '',140upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '',141provider: product.defaultChatAgent?.provider ?? { default: { id: '' }, enterprise: { id: '' } },142providerUriSetting: product.defaultChatAgent?.providerUriSetting ?? '',143providerScopes: product.defaultChatAgent?.providerScopes ?? [[]],144entitlementUrl: product.defaultChatAgent?.entitlementUrl ?? '',145entitlementSignupLimitedUrl: product.defaultChatAgent?.entitlementSignupLimitedUrl ?? '',146completionsAdvancedSetting: product.defaultChatAgent?.completionsAdvancedSetting ?? '',147chatQuotaExceededContext: product.defaultChatAgent?.chatQuotaExceededContext ?? '',148completionsQuotaExceededContext: product.defaultChatAgent?.completionsQuotaExceededContext ?? ''149};150151interface IChatQuotasAccessor {152clearQuotas(): void;153acceptQuotas(quotas: IQuotas): void;154}155156export class ChatEntitlementService extends Disposable implements IChatEntitlementService {157158declare _serviceBrand: undefined;159160readonly context: Lazy<ChatEntitlementContext> | undefined;161readonly requests: Lazy<ChatEntitlementRequests> | undefined;162163constructor(164@IInstantiationService instantiationService: IInstantiationService,165@IProductService productService: IProductService,166@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService,167@IContextKeyService private readonly contextKeyService: IContextKeyService,168@IConfigurationService configurationService: IConfigurationService169) {170super();171172this.chatQuotaExceededContextKey = ChatContextKeys.chatQuotaExceeded.bindTo(this.contextKeyService);173this.completionsQuotaExceededContextKey = ChatContextKeys.completionsQuotaExceeded.bindTo(this.contextKeyService);174175this.onDidChangeEntitlement = Event.map(176Event.filter(177this.contextKeyService.onDidChangeContext, e => e.affectsSome(new Set([178ChatContextKeys.Entitlement.planPro.key,179ChatContextKeys.Entitlement.planBusiness.key,180ChatContextKeys.Entitlement.planEnterprise.key,181ChatContextKeys.Entitlement.planProPlus.key,182ChatContextKeys.Entitlement.planFree.key,183ChatContextKeys.Entitlement.canSignUp.key,184ChatContextKeys.Entitlement.signedOut.key,185ChatContextKeys.Entitlement.organisations.key,186ChatContextKeys.Entitlement.internal.key,187ChatContextKeys.Entitlement.sku.key188])), this._store189), () => { }, this._store190);191192this.onDidChangeSentiment = Event.map(193Event.filter(194this.contextKeyService.onDidChangeContext, e => e.affectsSome(new Set([195ChatContextKeys.Setup.hidden.key,196ChatContextKeys.Setup.disabled.key,197ChatContextKeys.Setup.untrusted.key,198ChatContextKeys.Setup.installed.key,199ChatContextKeys.Setup.later.key200])), this._store201), () => { }, this._store202);203204if (205!productService.defaultChatAgent || // needs product config206(207// TODO@bpasero remove this condition and 'serverlessWebEnabled' once Chat web support lands208isWeb &&209!environmentService.remoteAuthority &&210!configurationService.getValue('chat.experimental.serverlessWebEnabled')211)212) {213ChatContextKeys.Setup.hidden.bindTo(this.contextKeyService).set(true); // hide copilot UI214return;215}216217const context = this.context = new Lazy(() => this._register(instantiationService.createInstance(ChatEntitlementContext)));218this.requests = new Lazy(() => this._register(instantiationService.createInstance(ChatEntitlementRequests, context.value, {219clearQuotas: () => this.clearQuotas(),220acceptQuotas: quotas => this.acceptQuotas(quotas)221})));222223this.registerListeners();224}225226//#region --- Entitlements227228readonly onDidChangeEntitlement: Event<void>;229230get entitlement(): ChatEntitlement {231if (this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Entitlement.planPro.key) === true) {232return ChatEntitlement.Pro;233} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Entitlement.planBusiness.key) === true) {234return ChatEntitlement.Business;235} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Entitlement.planEnterprise.key) === true) {236return ChatEntitlement.Enterprise;237} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Entitlement.planProPlus.key) === true) {238return ChatEntitlement.ProPlus;239} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Entitlement.planFree.key) === true) {240return ChatEntitlement.Free;241} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Entitlement.canSignUp.key) === true) {242return ChatEntitlement.Available;243} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Entitlement.signedOut.key) === true) {244return ChatEntitlement.Unknown;245}246247return ChatEntitlement.Unresolved;248}249250get isInternal(): boolean {251return this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Entitlement.internal.key) === true;252}253254get organisations(): string[] | undefined {255return this.contextKeyService.getContextKeyValue<string[]>(ChatContextKeys.Entitlement.organisations.key);256}257258get sku(): string | undefined {259return this.contextKeyService.getContextKeyValue<string>(ChatContextKeys.Entitlement.sku.key);260}261262//#endregion263264//#region --- Quotas265266private readonly _onDidChangeQuotaExceeded = this._register(new Emitter<void>());267readonly onDidChangeQuotaExceeded = this._onDidChangeQuotaExceeded.event;268269private readonly _onDidChangeQuotaRemaining = this._register(new Emitter<void>());270readonly onDidChangeQuotaRemaining = this._onDidChangeQuotaRemaining.event;271272private _quotas: IQuotas = {};273get quotas() { return this._quotas; }274275private readonly chatQuotaExceededContextKey: IContextKey<boolean>;276private readonly completionsQuotaExceededContextKey: IContextKey<boolean>;277278private ExtensionQuotaContextKeys = {279chatQuotaExceeded: defaultChat.chatQuotaExceededContext,280completionsQuotaExceeded: defaultChat.completionsQuotaExceededContext,281};282283private registerListeners(): void {284const quotaExceededSet = new Set([this.ExtensionQuotaContextKeys.chatQuotaExceeded, this.ExtensionQuotaContextKeys.completionsQuotaExceeded]);285286const cts = this._register(new MutableDisposable<CancellationTokenSource>());287this._register(this.contextKeyService.onDidChangeContext(e => {288if (e.affectsSome(quotaExceededSet)) {289if (cts.value) {290cts.value.cancel();291}292cts.value = new CancellationTokenSource();293this.update(cts.value.token);294}295}));296}297298acceptQuotas(quotas: IQuotas): void {299const oldQuota = this._quotas;300this._quotas = quotas;301this.updateContextKeys();302303const { changed: chatChanged } = this.compareQuotas(oldQuota.chat, quotas.chat);304const { changed: completionsChanged } = this.compareQuotas(oldQuota.completions, quotas.completions);305const { changed: premiumChatChanged } = this.compareQuotas(oldQuota.premiumChat, quotas.premiumChat);306307if (chatChanged.exceeded || completionsChanged.exceeded || premiumChatChanged.exceeded) {308this._onDidChangeQuotaExceeded.fire();309}310311if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining) {312this._onDidChangeQuotaRemaining.fire();313}314}315316private compareQuotas(oldQuota: IQuotaSnapshot | undefined, newQuota: IQuotaSnapshot | undefined): { changed: { exceeded: boolean; remaining: boolean } } {317return {318changed: {319exceeded: (oldQuota?.percentRemaining === 0) !== (newQuota?.percentRemaining === 0),320remaining: oldQuota?.percentRemaining !== newQuota?.percentRemaining321}322};323}324325clearQuotas(): void {326this.acceptQuotas({});327}328329private updateContextKeys(): void {330this.chatQuotaExceededContextKey.set(this._quotas.chat?.percentRemaining === 0);331this.completionsQuotaExceededContextKey.set(this._quotas.completions?.percentRemaining === 0);332}333334//#endregion335336//#region --- Sentiment337338readonly onDidChangeSentiment: Event<void>;339340get sentiment(): IChatSentiment {341return {342installed: this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Setup.installed.key) === true,343hidden: this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Setup.hidden.key) === true,344disabled: this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Setup.disabled.key) === true,345untrusted: this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Setup.untrusted.key) === true,346later: this.contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Setup.later.key) === true347};348}349350//#endregion351352async update(token: CancellationToken): Promise<void> {353await this.requests?.value.forceResolveEntitlement(undefined, token);354}355}356357//#endregion358359//#region Chat Entitlement Request Service360361type EntitlementClassification = {362tid: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; comment: 'The anonymized analytics id returned by the service'; endpoint: 'GoogleAnalyticsId' };363entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating the chat entitlement state' };364quotaChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat requests available to the user' };365quotaPremiumChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of premium chat requests available to the user' };366quotaCompletions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of code completions available to the user' };367quotaResetDate: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The date the quota will reset' };368owner: 'bpasero';369comment: 'Reporting chat entitlements';370};371372type EntitlementEvent = {373entitlement: ChatEntitlement;374tid: string;375quotaChat: number | undefined;376quotaPremiumChat: number | undefined;377quotaCompletions: number | undefined;378quotaResetDate: string | undefined;379};380381interface IQuotaSnapshotResponse {382readonly entitlement: number;383readonly overage_count: number;384readonly overage_permitted: boolean;385readonly percent_remaining: number;386readonly remaining: number;387readonly unlimited: boolean;388}389390interface ILegacyQuotaSnapshotResponse {391readonly limited_user_quotas?: {392readonly chat: number;393readonly completions: number;394};395readonly monthly_quotas?: {396readonly chat: number;397readonly completions: number;398};399}400401interface IEntitlementsResponse extends ILegacyQuotaSnapshotResponse {402readonly access_type_sku: string;403readonly assigned_date: string;404readonly can_signup_for_limited: boolean;405readonly chat_enabled: boolean;406readonly copilot_plan: string;407readonly organization_login_list: string[];408readonly analytics_tracking_id: string;409readonly limited_user_reset_date?: string; // for Copilot Free410readonly quota_reset_date?: string; // for all other Copilot SKUs411readonly quota_reset_date_utc?: string; // for all other Copilot SKUs (includes time)412readonly quota_snapshots?: {413chat?: IQuotaSnapshotResponse;414completions?: IQuotaSnapshotResponse;415premium_interactions?: IQuotaSnapshotResponse;416};417}418419interface IEntitlements {420readonly entitlement: ChatEntitlement;421readonly organisations?: string[];422readonly sku?: string;423readonly quotas?: IQuotas;424}425426export interface IQuotaSnapshot {427readonly total: number;428readonly percentRemaining: number;429430readonly overageEnabled: boolean;431readonly overageCount: number;432433readonly unlimited: boolean;434}435436interface IQuotas {437readonly resetDate?: string;438readonly resetDateHasTime?: boolean;439440readonly chat?: IQuotaSnapshot;441readonly completions?: IQuotaSnapshot;442readonly premiumChat?: IQuotaSnapshot;443}444445export class ChatEntitlementRequests extends Disposable {446447static providerId(configurationService: IConfigurationService): string {448if (configurationService.getValue<string | undefined>(`${defaultChat.completionsAdvancedSetting}.authProvider`) === defaultChat.provider.enterprise.id) {449return defaultChat.provider.enterprise.id;450}451452return defaultChat.provider.default.id;453}454455private state: IEntitlements;456457private pendingResolveCts = new CancellationTokenSource();458private didResolveEntitlements = false;459460constructor(461private readonly context: ChatEntitlementContext,462private readonly chatQuotasAccessor: IChatQuotasAccessor,463@ITelemetryService private readonly telemetryService: ITelemetryService,464@IAuthenticationService private readonly authenticationService: IAuthenticationService,465@ILogService private readonly logService: ILogService,466@IRequestService private readonly requestService: IRequestService,467@IDialogService private readonly dialogService: IDialogService,468@IOpenerService private readonly openerService: IOpenerService,469@IConfigurationService private readonly configurationService: IConfigurationService,470@IAuthenticationExtensionsService private readonly authenticationExtensionsService: IAuthenticationExtensionsService,471@ILifecycleService private readonly lifecycleService: ILifecycleService,472) {473super();474475this.state = { entitlement: this.context.state.entitlement };476477this.registerListeners();478479this.resolve();480}481482private registerListeners(): void {483this._register(this.authenticationService.onDidChangeDeclaredProviders(() => this.resolve()));484485this._register(this.authenticationService.onDidChangeSessions(e => {486if (e.providerId === ChatEntitlementRequests.providerId(this.configurationService)) {487this.resolve();488}489}));490491this._register(this.authenticationService.onDidRegisterAuthenticationProvider(e => {492if (e.id === ChatEntitlementRequests.providerId(this.configurationService)) {493this.resolve();494}495}));496497this._register(this.authenticationService.onDidUnregisterAuthenticationProvider(e => {498if (e.id === ChatEntitlementRequests.providerId(this.configurationService)) {499this.resolve();500}501}));502503this._register(this.context.onDidChange(() => {504if (!this.context.state.installed || this.context.state.disabled || this.context.state.entitlement === ChatEntitlement.Unknown) {505// When the extension is not installed, disabled or the user is not entitled506// make sure to clear quotas so that any indicators are also gone507this.state = { entitlement: this.state.entitlement, quotas: undefined };508this.chatQuotasAccessor.clearQuotas();509}510}));511}512513private async resolve(): Promise<void> {514this.pendingResolveCts.dispose(true);515const cts = this.pendingResolveCts = new CancellationTokenSource();516517const session = await this.findMatchingProviderSession(cts.token);518if (cts.token.isCancellationRequested) {519return;520}521522// Immediately signal whether we have a session or not523let state: IEntitlements | undefined = undefined;524if (session) {525// Do not overwrite any state we have already526if (this.state.entitlement === ChatEntitlement.Unknown) {527state = { entitlement: ChatEntitlement.Unresolved };528}529} else {530this.didResolveEntitlements = false; // reset so that we resolve entitlements fresh when signed in again531state = { entitlement: ChatEntitlement.Unknown };532}533if (state) {534this.update(state);535}536537if (session && !this.didResolveEntitlements) {538// Afterwards resolve entitlement with a network request539// but only unless it was not already resolved before.540await this.resolveEntitlement(session, cts.token);541}542}543544private async findMatchingProviderSession(token: CancellationToken): Promise<AuthenticationSession[] | undefined> {545const sessions = await this.doGetSessions(ChatEntitlementRequests.providerId(this.configurationService));546if (token.isCancellationRequested) {547return undefined;548}549550const matchingSessions = new Set<AuthenticationSession>();551for (const session of sessions) {552for (const scopes of defaultChat.providerScopes) {553if (this.includesScopes(session.scopes, scopes)) {554matchingSessions.add(session);555}556}557}558559// We intentionally want to return an array of matching sessions and560// not just the first, because it is possible that a matching session561// has an expired token. As such, we want to try them all until we562// succeeded with the request.563return matchingSessions.size > 0 ? Array.from(matchingSessions) : undefined;564}565566private async doGetSessions(providerId: string): Promise<readonly AuthenticationSession[]> {567const preferredAccountName = this.authenticationExtensionsService.getAccountPreference(defaultChat.chatExtensionId, providerId) ?? this.authenticationExtensionsService.getAccountPreference(defaultChat.extensionId, providerId);568let preferredAccount: AuthenticationSessionAccount | undefined;569for (const account of await this.authenticationService.getAccounts(providerId)) {570if (account.label === preferredAccountName) {571preferredAccount = account;572break;573}574}575576try {577return await this.authenticationService.getSessions(providerId, undefined, { account: preferredAccount });578} catch (error) {579// ignore - errors can throw if a provider is not registered580}581582return [];583}584585private includesScopes(scopes: ReadonlyArray<string>, expectedScopes: string[]): boolean {586return expectedScopes.every(scope => scopes.includes(scope));587}588589private async resolveEntitlement(sessions: AuthenticationSession[], token: CancellationToken): Promise<IEntitlements | undefined> {590const entitlements = await this.doResolveEntitlement(sessions, token);591if (typeof entitlements?.entitlement === 'number' && !token.isCancellationRequested) {592this.didResolveEntitlements = true;593this.update(entitlements);594}595596return entitlements;597}598599private async doResolveEntitlement(sessions: AuthenticationSession[], token: CancellationToken): Promise<IEntitlements | undefined> {600if (token.isCancellationRequested) {601return undefined;602}603604const response = await this.request(this.getEntitlementUrl(), 'GET', undefined, sessions, token);605if (token.isCancellationRequested) {606return undefined;607}608609if (!response) {610this.logService.trace('[chat entitlement]: no response');611return { entitlement: ChatEntitlement.Unresolved };612}613614if (response.res.statusCode && response.res.statusCode !== 200) {615this.logService.trace(`[chat entitlement]: unexpected status code ${response.res.statusCode}`);616return (617response.res.statusCode === 401 || // oauth token being unavailable (expired/revoked)618response.res.statusCode === 404 // missing scopes/permissions, service pretends the endpoint doesn't exist619) ? { entitlement: ChatEntitlement.Unknown /* treat as signed out */ } : { entitlement: ChatEntitlement.Unresolved };620}621622let responseText: string | null = null;623try {624responseText = await asText(response);625} catch (error) {626// ignore - handled below627}628if (token.isCancellationRequested) {629return undefined;630}631632if (!responseText) {633this.logService.trace('[chat entitlement]: response has no content');634return { entitlement: ChatEntitlement.Unresolved };635}636637let entitlementsResponse: IEntitlementsResponse;638try {639entitlementsResponse = JSON.parse(responseText);640this.logService.trace(`[chat entitlement]: parsed result is ${JSON.stringify(entitlementsResponse)}`);641} catch (err) {642this.logService.trace(`[chat entitlement]: error parsing response (${err})`);643return { entitlement: ChatEntitlement.Unresolved };644}645646let entitlement: ChatEntitlement;647if (entitlementsResponse.access_type_sku === 'free_limited_copilot') {648entitlement = ChatEntitlement.Free;649} else if (entitlementsResponse.can_signup_for_limited) {650entitlement = ChatEntitlement.Available;651} else if (entitlementsResponse.copilot_plan === 'individual') {652entitlement = ChatEntitlement.Pro;653} else if (entitlementsResponse.copilot_plan === 'individual_pro') {654entitlement = ChatEntitlement.ProPlus;655} else if (entitlementsResponse.copilot_plan === 'business') {656entitlement = ChatEntitlement.Business;657} else if (entitlementsResponse.copilot_plan === 'enterprise') {658entitlement = ChatEntitlement.Enterprise;659} else if (entitlementsResponse.chat_enabled) {660// This should never happen as we exhaustively list the plans above. But if a new plan is added in the future older clients won't break661entitlement = ChatEntitlement.Pro;662} else {663entitlement = ChatEntitlement.Unavailable;664}665666const entitlements: IEntitlements = {667entitlement,668organisations: entitlementsResponse.organization_login_list,669quotas: this.toQuotas(entitlementsResponse),670sku: entitlementsResponse.access_type_sku671};672673this.logService.trace(`[chat entitlement]: resolved to ${entitlements.entitlement}, quotas: ${JSON.stringify(entitlements.quotas)}`);674this.telemetryService.publicLog2<EntitlementEvent, EntitlementClassification>('chatInstallEntitlement', {675entitlement: entitlements.entitlement,676tid: entitlementsResponse.analytics_tracking_id,677quotaChat: entitlementsResponse?.quota_snapshots?.chat?.remaining,678quotaPremiumChat: entitlementsResponse?.quota_snapshots?.premium_interactions?.remaining,679quotaCompletions: entitlementsResponse?.quota_snapshots?.completions?.remaining,680quotaResetDate: entitlementsResponse.quota_reset_date_utc ?? entitlementsResponse.quota_reset_date ?? entitlementsResponse.limited_user_reset_date681});682683return entitlements;684}685686private getEntitlementUrl(): string {687if (ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider.enterprise.id) {688try {689const enterpriseUrl = new URL(this.configurationService.getValue(defaultChat.providerUriSetting));690return `${enterpriseUrl.protocol}//api.${enterpriseUrl.hostname}${enterpriseUrl.port ? ':' + enterpriseUrl.port : ''}/copilot_internal/user`;691} catch (error) {692this.logService.error(error);693}694}695696return defaultChat.entitlementUrl;697}698699private toQuotas(response: IEntitlementsResponse): IQuotas {700const quotas: Mutable<IQuotas> = {701resetDate: response.quota_reset_date_utc ?? response.quota_reset_date ?? response.limited_user_reset_date,702resetDateHasTime: typeof response.quota_reset_date_utc === 'string',703};704705// Legacy Free SKU Quota706if (response.monthly_quotas?.chat && typeof response.limited_user_quotas?.chat === 'number') {707quotas.chat = {708total: response.monthly_quotas.chat,709percentRemaining: Math.min(100, Math.max(0, (response.limited_user_quotas.chat / response.monthly_quotas.chat) * 100)),710overageEnabled: false,711overageCount: 0,712unlimited: false713};714}715716if (response.monthly_quotas?.completions && typeof response.limited_user_quotas?.completions === 'number') {717quotas.completions = {718total: response.monthly_quotas.completions,719percentRemaining: Math.min(100, Math.max(0, (response.limited_user_quotas.completions / response.monthly_quotas.completions) * 100)),720overageEnabled: false,721overageCount: 0,722unlimited: false723};724}725726// New Quota Snapshot727if (response.quota_snapshots) {728for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) {729const rawQuotaSnapshot = response.quota_snapshots[quotaType];730if (!rawQuotaSnapshot) {731continue;732}733const quotaSnapshot: IQuotaSnapshot = {734total: rawQuotaSnapshot.entitlement,735percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)),736overageEnabled: rawQuotaSnapshot.overage_permitted,737overageCount: rawQuotaSnapshot.overage_count,738unlimited: rawQuotaSnapshot.unlimited739};740741switch (quotaType) {742case 'chat':743quotas.chat = quotaSnapshot;744break;745case 'completions':746quotas.completions = quotaSnapshot;747break;748case 'premium_interactions':749quotas.premiumChat = quotaSnapshot;750break;751}752}753}754755return quotas;756}757758private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined>;759private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined>;760private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined> {761let lastRequest: IRequestContext | undefined;762763for (const session of sessions) {764if (token.isCancellationRequested) {765return lastRequest;766}767768try {769const response = await this.requestService.request({770type,771url,772data: type === 'POST' ? JSON.stringify(body) : undefined,773disableCache: true,774headers: {775'Authorization': `Bearer ${session.accessToken}`776}777}, token);778779const status = response.res.statusCode;780if (status && status !== 200) {781lastRequest = response;782continue; // try next session783}784785return response;786} catch (error) {787if (!token.isCancellationRequested) {788this.logService.error(`[chat entitlement] request: error ${error}`);789}790}791}792793return lastRequest;794}795796private update(state: IEntitlements): void {797this.state = state;798799this.context.update({ entitlement: this.state.entitlement, organisations: this.state.organisations, sku: this.state.sku });800801if (state.quotas) {802this.chatQuotasAccessor.acceptQuotas(state.quotas);803}804}805806async forceResolveEntitlement(sessions: AuthenticationSession[] | undefined, token = CancellationToken.None): Promise<IEntitlements | undefined> {807if (!sessions) {808sessions = await this.findMatchingProviderSession(token);809}810811if (!sessions || sessions.length === 0) {812return undefined;813}814815return this.resolveEntitlement(sessions, token);816}817818async signUpFree(sessions: AuthenticationSession[]): Promise<true /* signed up */ | false /* already signed up */ | { errorCode: number } /* error */> {819const body = {820restricted_telemetry: this.telemetryService.telemetryLevel === TelemetryLevel.NONE ? 'disabled' : 'enabled',821public_code_suggestions: 'enabled'822};823824const response = await this.request(defaultChat.entitlementSignupLimitedUrl, 'POST', body, sessions, CancellationToken.None);825if (!response) {826const retry = await this.onUnknownSignUpError(localize('signUpNoResponseError', "No response received."), '[chat entitlement] sign-up: no response');827return retry ? this.signUpFree(sessions) : { errorCode: 1 };828}829830if (response.res.statusCode && response.res.statusCode !== 200) {831if (response.res.statusCode === 422) {832try {833const responseText = await asText(response);834if (responseText) {835const responseError: { message: string } = JSON.parse(responseText);836if (typeof responseError.message === 'string' && responseError.message) {837this.onUnprocessableSignUpError(`[chat entitlement] sign-up: unprocessable entity (${responseError.message})`, responseError.message);838return { errorCode: response.res.statusCode };839}840}841} catch (error) {842// ignore - handled below843}844}845const retry = await this.onUnknownSignUpError(localize('signUpUnexpectedStatusError', "Unexpected status code {0}.", response.res.statusCode), `[chat entitlement] sign-up: unexpected status code ${response.res.statusCode}`);846return retry ? this.signUpFree(sessions) : { errorCode: response.res.statusCode };847}848849let responseText: string | null = null;850try {851responseText = await asText(response);852} catch (error) {853// ignore - handled below854}855856if (!responseText) {857const retry = await this.onUnknownSignUpError(localize('signUpNoResponseContentsError', "Response has no contents."), '[chat entitlement] sign-up: response has no content');858return retry ? this.signUpFree(sessions) : { errorCode: 2 };859}860861let parsedResult: { subscribed: boolean } | undefined = undefined;862try {863parsedResult = JSON.parse(responseText);864this.logService.trace(`[chat entitlement] sign-up: response is ${responseText}`);865} catch (err) {866const retry = await this.onUnknownSignUpError(localize('signUpInvalidResponseError', "Invalid response contents."), `[chat entitlement] sign-up: error parsing response (${err})`);867return retry ? this.signUpFree(sessions) : { errorCode: 3 };868}869870// We have made it this far, so the user either did sign-up or was signed-up already.871// That is, because the endpoint throws in all other case according to Patrick.872this.update({ entitlement: ChatEntitlement.Free });873874return Boolean(parsedResult?.subscribed);875}876877private async onUnknownSignUpError(detail: string, logMessage: string): Promise<boolean> {878this.logService.error(logMessage);879880if (!this.lifecycleService.willShutdown) {881const { confirmed } = await this.dialogService.confirm({882type: Severity.Error,883message: localize('unknownSignUpError', "An error occurred while signing up for the GitHub Copilot Free plan. Would you like to try again?"),884detail,885primaryButton: localize('retry', "Retry")886});887888return confirmed;889}890891return false;892}893894private onUnprocessableSignUpError(logMessage: string, logDetails: string): void {895this.logService.error(logMessage);896897if (!this.lifecycleService.willShutdown) {898this.dialogService.prompt({899type: Severity.Error,900message: localize('unprocessableSignUpError', "An error occurred while signing up for the GitHub Copilot Free plan."),901detail: logDetails,902buttons: [903{904label: localize('ok', "OK"),905run: () => { /* noop */ }906},907{908label: localize('learnMore', "Learn More"),909run: () => this.openerService.open(URI.parse(defaultChat.upgradePlanUrl))910}911]912});913}914}915916async signIn(options?: { useSocialProvider?: string; additionalScopes?: readonly string[] }) {917const providerId = ChatEntitlementRequests.providerId(this.configurationService);918919const scopes = options?.additionalScopes ? distinct([...defaultChat.providerScopes[0], ...options.additionalScopes]) : defaultChat.providerScopes[0];920const session = await this.authenticationService.createSession(921providerId,922scopes,923{924extraAuthorizeParameters: { get_started_with: 'copilot-vscode' },925provider: options?.useSocialProvider926});927928this.authenticationExtensionsService.updateAccountPreference(defaultChat.extensionId, providerId, session.account);929this.authenticationExtensionsService.updateAccountPreference(defaultChat.chatExtensionId, providerId, session.account);930931const entitlements = await this.forceResolveEntitlement([session]);932933return { session, entitlements };934}935936override dispose(): void {937this.pendingResolveCts.dispose(true);938939super.dispose();940}941}942943//#endregion944945//#region Context946947export interface IChatEntitlementContextState extends IChatSentiment {948949/**950* Users last known or resolved entitlement.951*/952entitlement: ChatEntitlement;953954/**955* User's last known or resolved raw SKU type.956*/957sku: string | undefined;958959/**960* User's last known or resolved organisations.961*/962organisations: string[] | undefined;963964/**965* User is or was a registered Chat user.966*/967registered?: boolean;968}969970type ChatEntitlementClassification = {971owner: 'bpasero';972comment: 'Provides insight into chat entitlements.';973chatHidden: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether chat is hidden or not.' };974chatEntitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The current chat entitlement of the user.' };975};976type ChatEntitlementEvent = {977chatHidden: boolean;978chatEntitlement: ChatEntitlement;979};980981export class ChatEntitlementContext extends Disposable {982983private static readonly CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY = 'chat.setupContext';984private static readonly CHAT_DISABLED_CONFIGURATION_KEY = 'chat.disableAIFeatures';985986private readonly canSignUpContextKey: IContextKey<boolean>;987private readonly signedOutContextKey: IContextKey<boolean>;988989private readonly freeContextKey: IContextKey<boolean>;990private readonly proContextKey: IContextKey<boolean>;991private readonly proPlusContextKey: IContextKey<boolean>;992private readonly businessContextKey: IContextKey<boolean>;993private readonly enterpriseContextKey: IContextKey<boolean>;994995private readonly organisationsContextKey: IContextKey<string[] | undefined>;996private readonly isInternalContextKey: IContextKey<boolean>;997private readonly skuContextKey: IContextKey<string | undefined>;998999private readonly hiddenContext: IContextKey<boolean>;1000private readonly laterContext: IContextKey<boolean>;1001private readonly installedContext: IContextKey<boolean>;1002private readonly disabledContext: IContextKey<boolean>;1003private readonly untrustedContext: IContextKey<boolean>;10041005private _state: IChatEntitlementContextState;1006private suspendedState: IChatEntitlementContextState | undefined = undefined;1007get state(): IChatEntitlementContextState { return this.withConfiguration(this.suspendedState ?? this._state); }10081009private readonly _onDidChange = this._register(new Emitter<void>());1010readonly onDidChange = this._onDidChange.event;10111012private updateBarrier: Barrier | undefined = undefined;10131014constructor(1015@IContextKeyService contextKeyService: IContextKeyService,1016@IStorageService private readonly storageService: IStorageService,1017@IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService,1018@ILogService private readonly logService: ILogService,1019@IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService,1020@IConfigurationService private readonly configurationService: IConfigurationService,1021@ITelemetryService private readonly telemetryService: ITelemetryService1022) {1023super();10241025this.canSignUpContextKey = ChatContextKeys.Entitlement.canSignUp.bindTo(contextKeyService);1026this.signedOutContextKey = ChatContextKeys.Entitlement.signedOut.bindTo(contextKeyService);1027this.freeContextKey = ChatContextKeys.Entitlement.planFree.bindTo(contextKeyService);1028this.proContextKey = ChatContextKeys.Entitlement.planPro.bindTo(contextKeyService);1029this.proPlusContextKey = ChatContextKeys.Entitlement.planProPlus.bindTo(contextKeyService);1030this.businessContextKey = ChatContextKeys.Entitlement.planBusiness.bindTo(contextKeyService);1031this.enterpriseContextKey = ChatContextKeys.Entitlement.planEnterprise.bindTo(contextKeyService);1032this.organisationsContextKey = ChatContextKeys.Entitlement.organisations.bindTo(contextKeyService);1033this.isInternalContextKey = ChatContextKeys.Entitlement.internal.bindTo(contextKeyService);1034this.skuContextKey = ChatContextKeys.Entitlement.sku.bindTo(contextKeyService);1035this.hiddenContext = ChatContextKeys.Setup.hidden.bindTo(contextKeyService);1036this.laterContext = ChatContextKeys.Setup.later.bindTo(contextKeyService);1037this.installedContext = ChatContextKeys.Setup.installed.bindTo(contextKeyService);1038this.disabledContext = ChatContextKeys.Setup.disabled.bindTo(contextKeyService);1039this.untrustedContext = ChatContextKeys.Setup.untrusted.bindTo(contextKeyService);10401041this._state = this.storageService.getObject<IChatEntitlementContextState>(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, StorageScope.PROFILE) ?? { entitlement: ChatEntitlement.Unknown, organisations: undefined, sku: undefined };10421043this.checkExtensionInstallation();1044this.updateContextSync();10451046this.registerListeners();1047}10481049private registerListeners(): void {1050this._register(this.configurationService.onDidChangeConfiguration(e => {1051if (e.affectsConfiguration(ChatEntitlementContext.CHAT_DISABLED_CONFIGURATION_KEY)) {1052this.updateContext();1053}1054}));1055}10561057private withConfiguration(state: IChatEntitlementContextState): IChatEntitlementContextState {1058if (this.configurationService.getValue(ChatEntitlementContext.CHAT_DISABLED_CONFIGURATION_KEY) === true) {1059// Setting always wins: if AI is disabled, set `hidden: true`1060return { ...state, hidden: true };1061}10621063return state;1064}10651066private async checkExtensionInstallation(): Promise<void> {10671068// Await extensions to be ready to be queried1069await this.extensionsWorkbenchService.queryLocal();10701071// Listen to extensions change and process extensions once1072this._register(Event.runAndSubscribe<IExtension | undefined>(this.extensionsWorkbenchService.onChange, e => {1073if (e && !ExtensionIdentifier.equals(e.identifier.id, defaultChat.chatExtensionId)) {1074return; // unrelated event1075}10761077const defaultChatExtension = this.extensionsWorkbenchService.local.find(value => ExtensionIdentifier.equals(value.identifier.id, defaultChat.chatExtensionId));1078const installed = !!defaultChatExtension?.local;10791080let disabled: boolean;1081let untrusted = false;1082if (installed) {1083disabled = !this.extensionEnablementService.isEnabled(defaultChatExtension.local);1084if (disabled) {1085const state = this.extensionEnablementService.getEnablementState(defaultChatExtension.local);1086if (state === EnablementState.DisabledByTrustRequirement) {1087disabled = false; // not disabled by user choice but1088untrusted = true; // by missing workspace trust1089}1090}1091} else {1092disabled = false;1093}10941095this.update({ installed, disabled, untrusted });1096}));1097}10981099update(context: { installed: boolean; disabled: boolean; untrusted: boolean }): Promise<void>;1100update(context: { hidden: false }): Promise<void>; // legacy UI state from before we had a setting to hide, keep around to still support users who used this1101update(context: { later: boolean }): Promise<void>;1102update(context: { entitlement: ChatEntitlement; organisations: string[] | undefined; sku: string | undefined }): Promise<void>;1103async update(context: { installed?: boolean; disabled?: boolean; untrusted?: boolean; hidden?: false; later?: boolean; entitlement?: ChatEntitlement; organisations?: string[]; sku?: string }): Promise<void> {1104this.logService.trace(`[chat entitlement context] update(): ${JSON.stringify(context)}`);11051106const oldState = JSON.stringify(this._state);11071108if (typeof context.installed === 'boolean' && typeof context.disabled === 'boolean' && typeof context.untrusted === 'boolean') {1109this._state.installed = context.installed;1110this._state.disabled = context.disabled;1111this._state.untrusted = context.untrusted;11121113if (context.installed && !context.disabled) {1114context.hidden = false; // treat this as a sign to make Chat visible again in case it is hidden1115}1116}11171118if (typeof context.hidden === 'boolean') {1119this._state.hidden = context.hidden;1120}11211122if (typeof context.later === 'boolean') {1123this._state.later = context.later;1124}11251126if (typeof context.entitlement === 'number') {1127this._state.entitlement = context.entitlement;1128this._state.organisations = context.organisations;1129this._state.sku = context.sku;11301131if (this._state.entitlement === ChatEntitlement.Free || isProUser(this._state.entitlement)) {1132this._state.registered = true;1133} else if (this._state.entitlement === ChatEntitlement.Available) {1134this._state.registered = false; // only reset when signed-in user can sign-up for free1135}1136}11371138if (oldState === JSON.stringify(this._state)) {1139return; // state did not change1140}11411142this.storageService.store(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, {1143...this._state,1144later: undefined // do not persist this across restarts for now1145}, StorageScope.PROFILE, StorageTarget.MACHINE);11461147return this.updateContext();1148}11491150private async updateContext(): Promise<void> {1151await this.updateBarrier?.wait();11521153this.updateContextSync();1154}11551156private updateContextSync(): void {1157const state = this.withConfiguration(this._state);11581159this.signedOutContextKey.set(state.entitlement === ChatEntitlement.Unknown);1160this.canSignUpContextKey.set(state.entitlement === ChatEntitlement.Available);11611162this.freeContextKey.set(state.entitlement === ChatEntitlement.Free);1163this.proContextKey.set(state.entitlement === ChatEntitlement.Pro);1164this.proPlusContextKey.set(state.entitlement === ChatEntitlement.ProPlus);1165this.businessContextKey.set(state.entitlement === ChatEntitlement.Business);1166this.enterpriseContextKey.set(state.entitlement === ChatEntitlement.Enterprise);11671168this.organisationsContextKey.set(state.organisations);1169this.isInternalContextKey.set(Boolean(state.organisations?.some(org => org === 'github' || org === 'microsoft')));1170this.skuContextKey.set(state.sku);11711172this.hiddenContext.set(!!state.hidden);1173this.laterContext.set(!!state.later);1174this.installedContext.set(!!state.installed);1175this.disabledContext.set(!!state.disabled);1176this.untrustedContext.set(!!state.untrusted);11771178this.logService.trace(`[chat entitlement context] updateContext(): ${JSON.stringify(state)}`);1179this.telemetryService.publicLog2<ChatEntitlementEvent, ChatEntitlementClassification>('chatEntitlements', {1180chatHidden: Boolean(state.hidden),1181chatEntitlement: state.entitlement1182});11831184this._onDidChange.fire();1185}11861187suspend(): void {1188this.suspendedState = { ...this._state };1189this.updateBarrier = new Barrier();1190}11911192resume(): void {1193this.suspendedState = undefined;1194this.updateBarrier?.open();1195this.updateBarrier = undefined;1196}1197}11981199//#endregion1200120112021203