Path: blob/main/src/vs/workbench/services/chat/common/chatEntitlementService.ts
5243 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, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';15import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';16import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';17import { ILogService } from '../../../../platform/log/common/log.js';18import { IProductService } from '../../../../platform/product/common/productService.js';19import { asText, IRequestService } from '../../../../platform/request/common/request.js';20import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';21import { ITelemetryService, TelemetryLevel } from '../../../../platform/telemetry/common/telemetry.js';22import { AuthenticationSession, IAuthenticationService } from '../../authentication/common/authentication.js';23import { IOpenerService } from '../../../../platform/opener/common/opener.js';24import { URI } from '../../../../base/common/uri.js';25import Severity from '../../../../base/common/severity.js';26import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';27import { isWeb } from '../../../../base/common/platform.js';28import { ILifecycleService } from '../../lifecycle/common/lifecycle.js';29import { Mutable } from '../../../../base/common/types.js';30import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';31import { IObservable, observableFromEvent } from '../../../../base/common/observable.js';32import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';33import { IDefaultAccount, IEntitlementsData } from '../../../../base/common/defaultAccount.js';3435export namespace ChatEntitlementContextKeys {3637export const Setup = {38hidden: new RawContextKey<boolean>('chatSetupHidden', false, true), // True when chat setup is explicitly hidden.39installed: new RawContextKey<boolean>('chatSetupInstalled', false, true), // True when the chat extension is installed and enabled.40disabled: new RawContextKey<boolean>('chatSetupDisabled', false, true), // True when the chat extension is disabled due to any other reason than workspace trust.41untrusted: new RawContextKey<boolean>('chatSetupUntrusted', false, true), // True when the chat extension is disabled due to workspace trust.42later: new RawContextKey<boolean>('chatSetupLater', false, true), // True when the user wants to finish setup later.43registered: new RawContextKey<boolean>('chatSetupRegistered', false, true) // True when the user has registered as Free or Pro user.44};4546export const Entitlement = {47signedOut: new RawContextKey<boolean>('chatEntitlementSignedOut', false, true), // True when user is signed out.48canSignUp: new RawContextKey<boolean>('chatPlanCanSignUp', false, true), // True when user can sign up to be a chat free user.4950planFree: new RawContextKey<boolean>('chatPlanFree', false, true), // True when user is a chat free user.51planPro: new RawContextKey<boolean>('chatPlanPro', false, true), // True when user is a chat pro user.52planProPlus: new RawContextKey<boolean>('chatPlanProPlus', false, true), // True when user is a chat pro plus user.53planBusiness: new RawContextKey<boolean>('chatPlanBusiness', false, true), // True when user is a chat business user.54planEnterprise: new RawContextKey<boolean>('chatPlanEnterprise', false, true), // True when user is a chat enterprise user.5556organisations: new RawContextKey<string[]>('chatEntitlementOrganisations', undefined, true), // The organizations the user belongs to.57internal: new RawContextKey<boolean>('chatEntitlementInternal', false, true), // True when user belongs to internal organisation.58sku: new RawContextKey<string>('chatEntitlementSku', undefined, true), // The SKU of the user.59};6061export const chatQuotaExceeded = new RawContextKey<boolean>('chatQuotaExceeded', false, true);62export const completionsQuotaExceeded = new RawContextKey<boolean>('completionsQuotaExceeded', false, true);6364export const chatAnonymous = new RawContextKey<boolean>('chatAnonymous', false, true);65}6667export const IChatEntitlementService = createDecorator<IChatEntitlementService>('chatEntitlementService');6869export enum ChatEntitlement {70/** Signed out */71Unknown = 1,72/** Signed in but not yet resolved */73Unresolved = 2,74/** Signed in and entitled to Free */75Available = 3,76/** Signed in but not entitled to Free */77Unavailable = 4,78/** Signed-up to Free */79Free = 5,80/** Signed-up to Pro */81Pro = 6,82/** Signed-up to Pro Plus */83ProPlus = 7,84/** Signed-up to Business */85Business = 8,86/** Signed-up to Enterprise */87Enterprise = 9,88}8990export interface IChatSentiment {9192/**93* User has Chat installed.94*/95installed?: boolean;9697/**98* User signals no intent in using Chat.99*100* Note: in contrast to `disabled`, this should not only disable101* Chat but also hide all of its UI.102*/103hidden?: boolean;104105/**106* User signals intent to disable Chat.107*108* Note: in contrast to `hidden`, this should not hide109* Chat but but disable its functionality.110*/111disabled?: boolean;112113/**114* Chat is disabled due to missing workspace trust.115*116* Note: even though this disables Chat, we want to treat it117* different from the `disabled` state that is by explicit118* user choice.119*/120untrusted?: boolean;121122/**123* User signals intent to use Chat later.124*/125later?: boolean;126127/**128* User has registered as Free or Pro user.129*/130registered?: boolean;131}132133export interface IChatEntitlementService {134135_serviceBrand: undefined;136137readonly onDidChangeEntitlement: Event<void>;138139readonly entitlement: ChatEntitlement;140readonly entitlementObs: IObservable<ChatEntitlement>;141142readonly previewFeaturesDisabled: boolean;143144readonly organisations: string[] | undefined;145readonly isInternal: boolean;146readonly sku: string | undefined;147readonly copilotTrackingId: string | undefined;148149readonly onDidChangeQuotaExceeded: Event<void>;150readonly onDidChangeQuotaRemaining: Event<void>;151152readonly quotas: IQuotas;153154readonly onDidChangeSentiment: Event<void>;155156readonly sentiment: IChatSentiment;157readonly sentimentObs: IObservable<IChatSentiment>;158159// TODO@bpasero eventually this will become enabled by default160// and in that case we only need to check on entitlements change161// between `unknown` and any other entitlement.162readonly onDidChangeAnonymous: Event<void>;163readonly anonymous: boolean;164readonly anonymousObs: IObservable<boolean>;165166update(token: CancellationToken): Promise<void>;167}168169//#region Helper Functions170171/**172* Checks the chat entitlements to see if the user falls into the paid category173* @param chatEntitlement The chat entitlement to check174* @returns Whether or not they are a paid user175*/176export function isProUser(chatEntitlement: ChatEntitlement): boolean {177return chatEntitlement === ChatEntitlement.Pro ||178chatEntitlement === ChatEntitlement.ProPlus ||179chatEntitlement === ChatEntitlement.Business ||180chatEntitlement === ChatEntitlement.Enterprise;181}182183/**184* Gets the full plan name for the given chat entitlement185* @param chatEntitlement The chat entitlement to get the plan name for186* @returns The localized full plan name (e.g., "Copilot Pro", "Copilot Free")187*/188export function getChatPlanName(chatEntitlement: ChatEntitlement): string {189switch (chatEntitlement) {190case ChatEntitlement.Pro:191return localize('plan.proName', 'Copilot Pro');192case ChatEntitlement.ProPlus:193return localize('plan.proPlusName', 'Copilot Pro+');194case ChatEntitlement.Business:195return localize('plan.businessName', 'Copilot Business');196case ChatEntitlement.Enterprise:197return localize('plan.enterpriseName', 'Copilot Enterprise');198default:199return localize('plan.freeName', 'Copilot Free');200}201}202203//#region Service Implementation204205const defaultChatAgent = {206upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '',207providerUriSetting: product.defaultChatAgent?.providerUriSetting ?? '',208entitlementSignupLimitedUrl: product.defaultChatAgent?.entitlementSignupLimitedUrl ?? '',209chatQuotaExceededContext: product.defaultChatAgent?.chatQuotaExceededContext ?? '',210completionsQuotaExceededContext: product.defaultChatAgent?.completionsQuotaExceededContext ?? ''211};212213interface IChatQuotasAccessor {214clearQuotas(): void;215acceptQuotas(quotas: IQuotas): void;216}217218const CHAT_ALLOW_ANONYMOUS_CONFIGURATION_KEY = 'chat.allowAnonymousAccess';219220function isAnonymous(configurationService: IConfigurationService, entitlement: ChatEntitlement, sentiment: IChatSentiment): boolean {221if (configurationService.getValue(CHAT_ALLOW_ANONYMOUS_CONFIGURATION_KEY) !== true) {222return false; // only enabled behind an experimental setting223}224225if (entitlement !== ChatEntitlement.Unknown) {226return false; // only consider signed out users227}228229if (sentiment.hidden || sentiment.disabled) {230return false; // only consider enabled scenarios231}232233return true;234}235236type ChatEntitlementClassification = {237owner: 'bpasero';238comment: 'Provides insight into chat entitlements.';239chatHidden: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether chat is hidden or not.' };240chatEntitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The current chat entitlement of the user.' };241chatAnonymous: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user is anonymously using chat.' };242chatRegistered: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user is registered for chat.' };243chatDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether chat is disabled or not.' };244};245type ChatEntitlementEvent = {246chatHidden: boolean;247chatEntitlement: ChatEntitlement;248chatAnonymous: boolean;249chatRegistered: boolean;250chatDisabled: boolean;251};252253function logChatEntitlements(state: IChatEntitlementContextState, configurationService: IConfigurationService, telemetryService: ITelemetryService): void {254telemetryService.publicLog2<ChatEntitlementEvent, ChatEntitlementClassification>('chatEntitlements', {255chatHidden: Boolean(state.hidden),256chatDisabled: Boolean(state.disabled),257chatEntitlement: state.entitlement,258chatRegistered: Boolean(state.registered),259chatAnonymous: isAnonymous(configurationService, state.entitlement, state)260});261}262263export class ChatEntitlementService extends Disposable implements IChatEntitlementService {264265declare _serviceBrand: undefined;266267readonly context: Lazy<ChatEntitlementContext> | undefined;268readonly requests: Lazy<ChatEntitlementRequests> | undefined;269270constructor(271@IInstantiationService instantiationService: IInstantiationService,272@IProductService productService: IProductService,273@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService,274@IContextKeyService private readonly contextKeyService: IContextKeyService,275@IConfigurationService private readonly configurationService: IConfigurationService,276@ITelemetryService private readonly telemetryService: ITelemetryService,277) {278super();279280this.chatQuotaExceededContextKey = ChatEntitlementContextKeys.chatQuotaExceeded.bindTo(this.contextKeyService);281this.completionsQuotaExceededContextKey = ChatEntitlementContextKeys.completionsQuotaExceeded.bindTo(this.contextKeyService);282283this.anonymousContextKey = ChatEntitlementContextKeys.chatAnonymous.bindTo(this.contextKeyService);284this.anonymousContextKey.set(this.anonymous);285286this.onDidChangeEntitlement = Event.map(287Event.filter(288this.contextKeyService.onDidChangeContext, e => e.affectsSome(new Set([289ChatEntitlementContextKeys.Entitlement.planPro.key,290ChatEntitlementContextKeys.Entitlement.planBusiness.key,291ChatEntitlementContextKeys.Entitlement.planEnterprise.key,292ChatEntitlementContextKeys.Entitlement.planProPlus.key,293ChatEntitlementContextKeys.Entitlement.planFree.key,294ChatEntitlementContextKeys.Entitlement.canSignUp.key,295ChatEntitlementContextKeys.Entitlement.signedOut.key,296ChatEntitlementContextKeys.Entitlement.organisations.key,297ChatEntitlementContextKeys.Entitlement.internal.key,298ChatEntitlementContextKeys.Entitlement.sku.key299])), this._store300), () => { }, this._store301);302this.entitlementObs = observableFromEvent(this.onDidChangeEntitlement, () => this.entitlement);303304this.onDidChangeSentiment = Event.map(305Event.filter(306this.contextKeyService.onDidChangeContext, e => e.affectsSome(new Set([307ChatEntitlementContextKeys.Setup.hidden.key,308ChatEntitlementContextKeys.Setup.disabled.key,309ChatEntitlementContextKeys.Setup.untrusted.key,310ChatEntitlementContextKeys.Setup.installed.key,311ChatEntitlementContextKeys.Setup.later.key,312ChatEntitlementContextKeys.Setup.registered.key313])), this._store314), () => { }, this._store315);316this.sentimentObs = observableFromEvent(this.onDidChangeSentiment, () => this.sentiment);317318if ((isWeb && !environmentService.remoteAuthority)) {319ChatEntitlementContextKeys.Setup.hidden.bindTo(this.contextKeyService).set(true); // hide copilot UI on web if unsupported320return;321}322323if (!productService.defaultChatAgent) {324return; // we need a default chat agent configured going forward from here325}326327const context = this.context = new Lazy(() => this._register(instantiationService.createInstance(ChatEntitlementContext)));328this.requests = new Lazy(() => this._register(instantiationService.createInstance(ChatEntitlementRequests, context.value, {329clearQuotas: () => this.clearQuotas(),330acceptQuotas: quotas => this.acceptQuotas(quotas)331})));332333this.registerListeners();334}335336//#region --- Entitlements337338readonly onDidChangeEntitlement: Event<void>;339readonly entitlementObs: IObservable<ChatEntitlement>;340341get entitlement(): ChatEntitlement {342if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planPro.key) === true) {343return ChatEntitlement.Pro;344} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planBusiness.key) === true) {345return ChatEntitlement.Business;346} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planEnterprise.key) === true) {347return ChatEntitlement.Enterprise;348} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planProPlus.key) === true) {349return ChatEntitlement.ProPlus;350} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planFree.key) === true) {351return ChatEntitlement.Free;352} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.canSignUp.key) === true) {353return ChatEntitlement.Available;354} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.signedOut.key) === true) {355return ChatEntitlement.Unknown;356}357358return ChatEntitlement.Unresolved;359}360361get isInternal(): boolean {362return this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.internal.key) === true;363}364365get organisations(): string[] | undefined {366return this.contextKeyService.getContextKeyValue<string[]>(ChatEntitlementContextKeys.Entitlement.organisations.key);367}368369get sku(): string | undefined {370return this.contextKeyService.getContextKeyValue<string>(ChatEntitlementContextKeys.Entitlement.sku.key);371}372373get copilotTrackingId(): string | undefined {374return this.context?.value.state.copilotTrackingId;375}376377get previewFeaturesDisabled(): boolean {378return this.contextKeyService.getContextKeyValue<boolean>('github.copilot.previewFeaturesDisabled') === true;379}380381//#endregion382383//#region --- Quotas384385private readonly _onDidChangeQuotaExceeded = this._register(new Emitter<void>());386readonly onDidChangeQuotaExceeded = this._onDidChangeQuotaExceeded.event;387388private readonly _onDidChangeQuotaRemaining = this._register(new Emitter<void>());389readonly onDidChangeQuotaRemaining = this._onDidChangeQuotaRemaining.event;390391private _quotas: IQuotas = {};392get quotas() { return this._quotas; }393394private readonly chatQuotaExceededContextKey: IContextKey<boolean>;395private readonly completionsQuotaExceededContextKey: IContextKey<boolean>;396397private ExtensionQuotaContextKeys = {398chatQuotaExceeded: defaultChatAgent.chatQuotaExceededContext,399completionsQuotaExceeded: defaultChatAgent.completionsQuotaExceededContext,400};401402private registerListeners(): void {403const quotaExceededSet = new Set([this.ExtensionQuotaContextKeys.chatQuotaExceeded, this.ExtensionQuotaContextKeys.completionsQuotaExceeded]);404405const cts = this._register(new MutableDisposable<CancellationTokenSource>());406this._register(this.contextKeyService.onDidChangeContext(e => {407if (e.affectsSome(quotaExceededSet)) {408if (cts.value) {409cts.value.cancel();410}411cts.value = new CancellationTokenSource();412this.update(cts.value.token);413}414}));415416let anonymousUsage = this.anonymous;417418const updateAnonymousUsage = () => {419const newAnonymousUsage = this.anonymous;420if (newAnonymousUsage !== anonymousUsage) {421anonymousUsage = newAnonymousUsage;422this.anonymousContextKey.set(newAnonymousUsage);423424if (this.context?.hasValue) {425logChatEntitlements(this.context.value.state, this.configurationService, this.telemetryService);426}427428this._onDidChangeAnonymous.fire();429}430};431432this._register(this.configurationService.onDidChangeConfiguration(e => {433if (e.affectsConfiguration(CHAT_ALLOW_ANONYMOUS_CONFIGURATION_KEY)) {434updateAnonymousUsage();435}436}));437438this._register(this.onDidChangeEntitlement(() => updateAnonymousUsage()));439this._register(this.onDidChangeSentiment(() => updateAnonymousUsage()));440}441442acceptQuotas(quotas: IQuotas): void {443const oldQuota = this._quotas;444this._quotas = quotas;445this.updateContextKeys();446447const { changed: chatChanged } = this.compareQuotas(oldQuota.chat, quotas.chat);448const { changed: completionsChanged } = this.compareQuotas(oldQuota.completions, quotas.completions);449const { changed: premiumChatChanged } = this.compareQuotas(oldQuota.premiumChat, quotas.premiumChat);450451if (chatChanged.exceeded || completionsChanged.exceeded || premiumChatChanged.exceeded) {452this._onDidChangeQuotaExceeded.fire();453}454455if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining) {456this._onDidChangeQuotaRemaining.fire();457}458}459460private compareQuotas(oldQuota: IQuotaSnapshot | undefined, newQuota: IQuotaSnapshot | undefined): { changed: { exceeded: boolean; remaining: boolean } } {461return {462changed: {463exceeded: (oldQuota?.percentRemaining === 0) !== (newQuota?.percentRemaining === 0),464remaining: oldQuota?.percentRemaining !== newQuota?.percentRemaining465}466};467}468469clearQuotas(): void {470this.acceptQuotas({});471}472473private updateContextKeys(): void {474this.chatQuotaExceededContextKey.set(this._quotas.chat?.percentRemaining === 0);475this.completionsQuotaExceededContextKey.set(this._quotas.completions?.percentRemaining === 0);476}477478//#endregion479480//#region --- Sentiment481482readonly onDidChangeSentiment: Event<void>;483readonly sentimentObs: IObservable<IChatSentiment>;484485get sentiment(): IChatSentiment {486return {487installed: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.installed.key) === true,488hidden: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.hidden.key) === true,489disabled: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.disabled.key) === true,490untrusted: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.untrusted.key) === true,491later: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.later.key) === true,492registered: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.registered.key) === true493};494}495496//#endregion497498//region --- Anonymous499500private readonly anonymousContextKey: IContextKey<boolean>;501502private readonly _onDidChangeAnonymous = this._register(new Emitter<void>());503readonly onDidChangeAnonymous = this._onDidChangeAnonymous.event;504505readonly anonymousObs = observableFromEvent(this.onDidChangeAnonymous, () => this.anonymous);506507get anonymous(): boolean {508return isAnonymous(this.configurationService, this.entitlement, this.sentiment);509}510511//#endregion512513async update(token: CancellationToken): Promise<void> {514await this.requests?.value.forceResolveEntitlement(token);515}516}517518//#endregion519520//#region Chat Entitlement Request Service521522type EntitlementClassification = {523tid: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; comment: 'The anonymized analytics id returned by the service'; endpoint: 'GoogleAnalyticsId' };524entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating the chat entitlement state' };525sku: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The SKU of the chat entitlement' };526quotaChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat requests available to the user' };527quotaPremiumChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of premium chat requests available to the user' };528quotaCompletions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of inline suggestions available to the user' };529quotaResetDate: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The date the quota will reset' };530owner: 'bpasero';531comment: 'Reporting chat entitlements';532};533534type EntitlementEvent = {535entitlement: ChatEntitlement;536tid: string;537sku: string | undefined;538quotaChat: number | undefined;539quotaPremiumChat: number | undefined;540quotaCompletions: number | undefined;541quotaResetDate: string | undefined;542};543544interface IEntitlements {545readonly entitlement: ChatEntitlement;546readonly organisations?: string[];547readonly sku?: string;548readonly copilotTrackingId?: string;549readonly quotas?: IQuotas;550}551552export interface IQuotaSnapshot {553readonly total: number;554555readonly remaining: number;556readonly percentRemaining: number;557558readonly overageEnabled: boolean;559readonly overageCount: number;560561readonly unlimited: boolean;562}563564interface IQuotas {565readonly resetDate?: string;566readonly resetDateHasTime?: boolean;567568readonly chat?: IQuotaSnapshot;569readonly completions?: IQuotaSnapshot;570readonly premiumChat?: IQuotaSnapshot;571}572573export class ChatEntitlementRequests extends Disposable {574575private state: IEntitlements;576577private pendingResolveCts = new CancellationTokenSource();578579constructor(580private readonly context: ChatEntitlementContext,581private readonly chatQuotasAccessor: IChatQuotasAccessor,582@ITelemetryService private readonly telemetryService: ITelemetryService,583@ILogService private readonly logService: ILogService,584@IRequestService private readonly requestService: IRequestService,585@IDialogService private readonly dialogService: IDialogService,586@IOpenerService private readonly openerService: IOpenerService,587@ILifecycleService private readonly lifecycleService: ILifecycleService,588@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,589@IAuthenticationService private readonly authenticationService: IAuthenticationService,590) {591super();592593this.state = { entitlement: this.context.state.entitlement };594595this.registerListeners();596597this.resolve();598}599600private registerListeners(): void {601this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.resolve()));602603this._register(this.context.onDidChange(() => {604if (!this.context.state.installed || this.context.state.disabled || this.context.state.entitlement === ChatEntitlement.Unknown) {605// When the extension is not installed, disabled or the user is not entitled606// make sure to clear quotas so that any indicators are also gone607this.state = { entitlement: this.state.entitlement, quotas: undefined };608this.chatQuotasAccessor.clearQuotas();609}610}));611}612613private async resolve(): Promise<void> {614this.pendingResolveCts.dispose(true);615const cts = this.pendingResolveCts = new CancellationTokenSource();616617const defaultAccount = await this.defaultAccountService.getDefaultAccount();618if (cts.token.isCancellationRequested) {619return;620}621622// Immediately signal whether we have a session or not623let state: IEntitlements | undefined = undefined;624if (defaultAccount) {625// Do not overwrite any state we have already626if (this.state.entitlement === ChatEntitlement.Unknown) {627state = { entitlement: ChatEntitlement.Unresolved };628}629} else {630state = { entitlement: ChatEntitlement.Unknown };631}632if (state) {633this.update(state);634}635636if (defaultAccount) {637// Afterwards resolve entitlement with a network request638// but only unless it was not already resolved before.639await this.resolveEntitlement(defaultAccount, cts.token);640}641}642643private async resolveEntitlement(defaultAccount: IDefaultAccount, token: CancellationToken): Promise<IEntitlements | undefined> {644const entitlements = await this.doResolveEntitlement(defaultAccount, token);645if (typeof entitlements?.entitlement === 'number' && !token.isCancellationRequested) {646this.update(entitlements);647}648return entitlements;649}650651private async doResolveEntitlement(defaultAccount: IDefaultAccount, token: CancellationToken): Promise<IEntitlements | undefined> {652if (token.isCancellationRequested) {653return undefined;654}655656const entitlementsData = defaultAccount.entitlementsData;657if (!entitlementsData) {658this.logService.trace('[chat entitlement]: no entitlements data available on default account');659return { entitlement: entitlementsData === null ? ChatEntitlement.Unknown : ChatEntitlement.Unresolved };660}661662let entitlement: ChatEntitlement;663if (entitlementsData.access_type_sku === 'free_limited_copilot') {664entitlement = ChatEntitlement.Free;665} else if (entitlementsData.can_signup_for_limited) {666entitlement = ChatEntitlement.Available;667} else if (entitlementsData.copilot_plan === 'individual') {668entitlement = ChatEntitlement.Pro;669} else if (entitlementsData.copilot_plan === 'individual_pro') {670entitlement = ChatEntitlement.ProPlus;671} else if (entitlementsData.copilot_plan === 'business') {672entitlement = ChatEntitlement.Business;673} else if (entitlementsData.copilot_plan === 'enterprise') {674entitlement = ChatEntitlement.Enterprise;675} else {676entitlement = ChatEntitlement.Unavailable;677}678679const entitlements: IEntitlements = {680entitlement,681organisations: entitlementsData.organization_login_list,682quotas: this.toQuotas(entitlementsData),683sku: entitlementsData.access_type_sku,684copilotTrackingId: entitlementsData.analytics_tracking_id685};686687this.logService.trace(`[chat entitlement]: resolved to ${entitlements.entitlement}, quotas: ${JSON.stringify(entitlements.quotas)}`);688this.telemetryService.publicLog2<EntitlementEvent, EntitlementClassification>('chatInstallEntitlement', {689entitlement: entitlements.entitlement,690tid: entitlementsData.analytics_tracking_id,691sku: entitlements.sku,692quotaChat: entitlements.quotas?.chat?.remaining,693quotaPremiumChat: entitlements.quotas?.premiumChat?.remaining,694quotaCompletions: entitlements.quotas?.completions?.remaining,695quotaResetDate: entitlements.quotas?.resetDate696});697698return entitlements;699}700701private toQuotas(entitlementsData: IEntitlementsData): IQuotas {702const quotas: Mutable<IQuotas> = {703resetDate: entitlementsData.quota_reset_date_utc ?? entitlementsData.quota_reset_date ?? entitlementsData.limited_user_reset_date,704resetDateHasTime: typeof entitlementsData.quota_reset_date_utc === 'string',705};706707// Legacy Free SKU Quota708if (entitlementsData.monthly_quotas?.chat && typeof entitlementsData.limited_user_quotas?.chat === 'number') {709quotas.chat = {710total: entitlementsData.monthly_quotas.chat,711remaining: entitlementsData.limited_user_quotas.chat,712percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.chat / entitlementsData.monthly_quotas.chat) * 100)),713overageEnabled: false,714overageCount: 0,715unlimited: false716};717}718719if (entitlementsData.monthly_quotas?.completions && typeof entitlementsData.limited_user_quotas?.completions === 'number') {720quotas.completions = {721total: entitlementsData.monthly_quotas.completions,722remaining: entitlementsData.limited_user_quotas.completions,723percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.completions / entitlementsData.monthly_quotas.completions) * 100)),724overageEnabled: false,725overageCount: 0,726unlimited: false727};728}729730// New Quota Snapshot731if (entitlementsData.quota_snapshots) {732for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) {733const rawQuotaSnapshot = entitlementsData.quota_snapshots[quotaType];734if (!rawQuotaSnapshot) {735continue;736}737const quotaSnapshot: IQuotaSnapshot = {738total: rawQuotaSnapshot.entitlement,739remaining: rawQuotaSnapshot.remaining,740percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)),741overageEnabled: rawQuotaSnapshot.overage_permitted,742overageCount: rawQuotaSnapshot.overage_count,743unlimited: rawQuotaSnapshot.unlimited744};745746switch (quotaType) {747case 'chat':748quotas.chat = quotaSnapshot;749break;750case 'completions':751quotas.completions = quotaSnapshot;752break;753case 'premium_interactions':754quotas.premiumChat = quotaSnapshot;755break;756}757}758}759760return quotas;761}762763private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined>;764private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined>;765private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined> {766let lastRequest: IRequestContext | undefined;767768for (const session of sessions) {769if (token.isCancellationRequested) {770return lastRequest;771}772773try {774const response = await this.requestService.request({775type,776url,777data: type === 'POST' ? JSON.stringify(body) : undefined,778disableCache: true,779headers: {780'Authorization': `Bearer ${session.accessToken}`781}782}, token);783784const status = response.res.statusCode;785if (status && status !== 200) {786lastRequest = response;787continue; // try next session788}789790return response;791} catch (error) {792if (!token.isCancellationRequested) {793this.logService.error(`[chat entitlement] request: error ${error}`);794}795}796}797798return lastRequest;799}800801private update(state: IEntitlements): void {802this.state = state;803804this.context.update({ entitlement: this.state.entitlement, organisations: this.state.organisations, sku: this.state.sku, copilotTrackingId: this.state.copilotTrackingId });805806if (state.quotas) {807this.chatQuotasAccessor.acceptQuotas(state.quotas);808}809}810811async forceResolveEntitlement(token = CancellationToken.None): Promise<IEntitlements | undefined> {812const defaultAccount = await this.defaultAccountService.refresh();813if (!defaultAccount) {814return undefined;815}816817return this.resolveEntitlement(defaultAccount, token);818}819820async signUpFree(): Promise<true /* signed up */ | false /* already signed up */ | { errorCode: number } /* error */ | undefined /* no session */> {821const sessions = await this.getSessions();822if (sessions.length === 0) {823return undefined;824}825return this.doSignUpFree(sessions);826}827828private async doSignUpFree(sessions: AuthenticationSession[]): Promise<true /* signed up */ | false /* already signed up */ | { errorCode: number } /* error */> {829const body = {830restricted_telemetry: this.telemetryService.telemetryLevel === TelemetryLevel.NONE ? 'disabled' : 'enabled',831public_code_suggestions: 'enabled'832};833834const response = await this.request(defaultChatAgent.entitlementSignupLimitedUrl, 'POST', body, sessions, CancellationToken.None);835if (!response) {836const retry = await this.onUnknownSignUpError(localize('signUpNoResponseError', "No response received."), '[chat entitlement] sign-up: no response');837return retry ? this.doSignUpFree(sessions) : { errorCode: 1 };838}839840if (response.res.statusCode && response.res.statusCode !== 200) {841if (response.res.statusCode === 422) {842try {843const responseText = await asText(response);844if (responseText) {845const responseError: { message: string } = JSON.parse(responseText);846if (typeof responseError.message === 'string' && responseError.message) {847this.onUnprocessableSignUpError(`[chat entitlement] sign-up: unprocessable entity (${responseError.message})`, responseError.message);848return { errorCode: response.res.statusCode };849}850}851} catch (error) {852// ignore - handled below853}854}855const retry = await this.onUnknownSignUpError(localize('signUpUnexpectedStatusError', "Unexpected status code {0}.", response.res.statusCode), `[chat entitlement] sign-up: unexpected status code ${response.res.statusCode}`);856return retry ? this.doSignUpFree(sessions) : { errorCode: response.res.statusCode };857}858859let responseText: string | null = null;860try {861responseText = await asText(response);862} catch (error) {863// ignore - handled below864}865866if (!responseText) {867const retry = await this.onUnknownSignUpError(localize('signUpNoResponseContentsError', "Response has no contents."), '[chat entitlement] sign-up: response has no content');868return retry ? this.doSignUpFree(sessions) : { errorCode: 2 };869}870871let parsedResult: { subscribed: boolean } | undefined = undefined;872try {873parsedResult = JSON.parse(responseText);874this.logService.trace(`[chat entitlement] sign-up: response is ${responseText}`);875} catch (err) {876const retry = await this.onUnknownSignUpError(localize('signUpInvalidResponseError', "Invalid response contents."), `[chat entitlement] sign-up: error parsing response (${err})`);877return retry ? this.doSignUpFree(sessions) : { errorCode: 3 };878}879880// We have made it this far, so the user either did sign-up or was signed-up already.881// That is, because the endpoint throws in all other case according to Patrick.882this.update({ entitlement: ChatEntitlement.Free });883884return Boolean(parsedResult?.subscribed);885}886887private async getSessions(): Promise<AuthenticationSession[]> {888const defaultAccount = await this.defaultAccountService.getDefaultAccount();889if (defaultAccount) {890const sessions = await this.authenticationService.getSessions(defaultAccount.authenticationProvider.id);891const accountSessions = sessions.filter(s => s.id === defaultAccount.sessionId);892if (accountSessions.length) {893return accountSessions;894}895}896return [...(await this.authenticationService.getSessions(this.defaultAccountService.getDefaultAccountAuthenticationProvider().id))];897}898899private async onUnknownSignUpError(detail: string, logMessage: string): Promise<boolean> {900this.logService.error(logMessage);901902if (!this.lifecycleService.willShutdown) {903const { confirmed } = await this.dialogService.confirm({904type: Severity.Error,905message: localize('unknownSignUpError', "An error occurred while signing up for the GitHub Copilot Free plan. Would you like to try again?"),906detail,907primaryButton: localize('retry', "Retry")908});909910return confirmed;911}912913return false;914}915916private onUnprocessableSignUpError(logMessage: string, logDetails: string): void {917this.logService.error(logMessage);918919if (!this.lifecycleService.willShutdown) {920this.dialogService.prompt({921type: Severity.Error,922message: localize('unprocessableSignUpError', "An error occurred while signing up for the GitHub Copilot Free plan."),923detail: logDetails,924buttons: [925{926label: localize('ok', "OK"),927run: () => { /* noop */ }928},929{930label: localize('learnMore', "Learn More"),931run: () => this.openerService.open(URI.parse(defaultChatAgent.upgradePlanUrl))932}933]934});935}936}937938async signIn(options?: { useSocialProvider?: string; additionalScopes?: readonly string[] }): Promise<{ defaultAccount?: IDefaultAccount; entitlements?: IEntitlements }> {939const defaultAccount = await this.defaultAccountService.signIn({940additionalScopes: options?.additionalScopes,941extraAuthorizeParameters: { get_started_with: 'copilot-vscode' },942provider: options?.useSocialProvider943});944if (!defaultAccount) {945return {};946}947948const entitlements = await this.doResolveEntitlement(defaultAccount, CancellationToken.None);949return { defaultAccount, entitlements };950}951952override dispose(): void {953this.pendingResolveCts.dispose(true);954955super.dispose();956}957}958959//#endregion960961//#region Context962963export interface IChatEntitlementContextState extends IChatSentiment {964965/**966* Users last known or resolved entitlement.967*/968entitlement: ChatEntitlement;969970/**971* User's last known or resolved raw SKU type.972*/973sku: string | undefined;974975/**976* User's last known or resolved organisations.977*/978organisations: string[] | undefined;979980/**981* User's Copilot tracking ID from the entitlement API.982*/983copilotTrackingId: string | undefined;984985/**986* User is or was a registered Chat user.987*/988registered?: boolean;989}990991export class ChatEntitlementContext extends Disposable {992993private static readonly CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY = 'chat.setupContext';994995private static readonly CHAT_DISABLED_CONFIGURATION_KEY = 'chat.disableAIFeatures';996997private readonly canSignUpContextKey: IContextKey<boolean>;998private readonly signedOutContextKey: IContextKey<boolean>;9991000private readonly freeContextKey: IContextKey<boolean>;1001private readonly proContextKey: IContextKey<boolean>;1002private readonly proPlusContextKey: IContextKey<boolean>;1003private readonly businessContextKey: IContextKey<boolean>;1004private readonly enterpriseContextKey: IContextKey<boolean>;10051006private readonly organisationsContextKey: IContextKey<string[] | undefined>;1007private readonly isInternalContextKey: IContextKey<boolean>;1008private readonly skuContextKey: IContextKey<string | undefined>;10091010private readonly hiddenContext: IContextKey<boolean>;1011private readonly laterContext: IContextKey<boolean>;1012private readonly installedContext: IContextKey<boolean>;1013private readonly disabledContext: IContextKey<boolean>;1014private readonly untrustedContext: IContextKey<boolean>;1015private readonly registeredContext: IContextKey<boolean>;10161017private _state: IChatEntitlementContextState;1018private suspendedState: IChatEntitlementContextState | undefined = undefined;1019get state(): IChatEntitlementContextState { return this.withConfiguration(this.suspendedState ?? this._state); }10201021private readonly _onDidChange = this._register(new Emitter<void>());1022readonly onDidChange = this._onDidChange.event;10231024private updateBarrier: Barrier | undefined = undefined;10251026constructor(1027@IContextKeyService contextKeyService: IContextKeyService,1028@IStorageService private readonly storageService: IStorageService,1029@ILogService private readonly logService: ILogService,1030@IConfigurationService private readonly configurationService: IConfigurationService,1031@ITelemetryService private readonly telemetryService: ITelemetryService1032) {1033super();10341035this.canSignUpContextKey = ChatEntitlementContextKeys.Entitlement.canSignUp.bindTo(contextKeyService);1036this.signedOutContextKey = ChatEntitlementContextKeys.Entitlement.signedOut.bindTo(contextKeyService);10371038this.freeContextKey = ChatEntitlementContextKeys.Entitlement.planFree.bindTo(contextKeyService);1039this.proContextKey = ChatEntitlementContextKeys.Entitlement.planPro.bindTo(contextKeyService);1040this.proPlusContextKey = ChatEntitlementContextKeys.Entitlement.planProPlus.bindTo(contextKeyService);1041this.businessContextKey = ChatEntitlementContextKeys.Entitlement.planBusiness.bindTo(contextKeyService);1042this.enterpriseContextKey = ChatEntitlementContextKeys.Entitlement.planEnterprise.bindTo(contextKeyService);10431044this.organisationsContextKey = ChatEntitlementContextKeys.Entitlement.organisations.bindTo(contextKeyService);1045this.isInternalContextKey = ChatEntitlementContextKeys.Entitlement.internal.bindTo(contextKeyService);1046this.skuContextKey = ChatEntitlementContextKeys.Entitlement.sku.bindTo(contextKeyService);10471048this.hiddenContext = ChatEntitlementContextKeys.Setup.hidden.bindTo(contextKeyService);1049this.laterContext = ChatEntitlementContextKeys.Setup.later.bindTo(contextKeyService);1050this.installedContext = ChatEntitlementContextKeys.Setup.installed.bindTo(contextKeyService);1051this.disabledContext = ChatEntitlementContextKeys.Setup.disabled.bindTo(contextKeyService);1052this.untrustedContext = ChatEntitlementContextKeys.Setup.untrusted.bindTo(contextKeyService);1053this.registeredContext = ChatEntitlementContextKeys.Setup.registered.bindTo(contextKeyService);10541055this._state = this.storageService.getObject<IChatEntitlementContextState>(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, StorageScope.PROFILE) ?? { entitlement: ChatEntitlement.Unknown, organisations: undefined, sku: undefined, copilotTrackingId: undefined };10561057this.updateContextSync();10581059this.registerListeners();1060}10611062private registerListeners(): void {1063this._register(this.configurationService.onDidChangeConfiguration(e => {1064if (e.affectsConfiguration(ChatEntitlementContext.CHAT_DISABLED_CONFIGURATION_KEY)) {1065this.updateContext();1066}1067}));1068}10691070private withConfiguration(state: IChatEntitlementContextState): IChatEntitlementContextState {1071if (this.configurationService.getValue(ChatEntitlementContext.CHAT_DISABLED_CONFIGURATION_KEY) === true) {1072return {1073...state,1074hidden: true // Setting always wins: if AI is disabled, set `hidden: true`1075};1076}10771078return state;1079}10801081update(context: { installed: boolean; disabled: boolean; untrusted: boolean }): Promise<void>;1082update(context: { hidden: false }): Promise<void>; // legacy UI state from before we had a setting to hide, keep around to still support users who used this1083update(context: { later: boolean }): Promise<void>;1084update(context: { entitlement: ChatEntitlement; organisations: string[] | undefined; sku: string | undefined; copilotTrackingId: string | undefined }): Promise<void>;1085async update(context: { installed?: boolean; disabled?: boolean; untrusted?: boolean; hidden?: false; later?: boolean; entitlement?: ChatEntitlement; organisations?: string[]; sku?: string; copilotTrackingId?: string }): Promise<void> {1086this.logService.trace(`[chat entitlement context] update(): ${JSON.stringify(context)}`);10871088const oldState = JSON.stringify(this._state);10891090if (typeof context.installed === 'boolean' && typeof context.disabled === 'boolean' && typeof context.untrusted === 'boolean') {1091this._state.installed = context.installed;1092this._state.disabled = context.disabled;1093this._state.untrusted = context.untrusted;10941095if (context.installed && !context.disabled) {1096context.hidden = false; // treat this as a sign to make Chat visible again in case it is hidden1097}1098}10991100if (typeof context.hidden === 'boolean') {1101this._state.hidden = context.hidden;1102}11031104if (typeof context.later === 'boolean') {1105this._state.later = context.later;1106}11071108if (typeof context.entitlement === 'number') {1109this._state.entitlement = context.entitlement;1110this._state.organisations = context.organisations;1111this._state.sku = context.sku;1112this._state.copilotTrackingId = context.copilotTrackingId;11131114if (this._state.entitlement === ChatEntitlement.Free || isProUser(this._state.entitlement)) {1115this._state.registered = true;1116} else if (this._state.entitlement === ChatEntitlement.Available) {1117this._state.registered = false; // only reset when signed-in user can sign-up for free1118}1119}11201121if (isAnonymous(this.configurationService, this._state.entitlement, this._state)) {1122this._state.sku = 'no_auth_limited_copilot'; // no-auth users have a fixed SKU1123}11241125if (oldState === JSON.stringify(this._state)) {1126return; // state did not change1127}11281129this.storageService.store(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, {1130...this._state,1131later: undefined // do not persist this across restarts for now1132}, StorageScope.PROFILE, StorageTarget.MACHINE);11331134return this.updateContext();1135}11361137private async updateContext(): Promise<void> {1138await this.updateBarrier?.wait();11391140this.updateContextSync();1141}11421143private updateContextSync(): void {1144const state = this.withConfiguration(this._state);11451146this.signedOutContextKey.set(state.entitlement === ChatEntitlement.Unknown);1147this.canSignUpContextKey.set(state.entitlement === ChatEntitlement.Available);11481149this.freeContextKey.set(state.entitlement === ChatEntitlement.Free);1150this.proContextKey.set(state.entitlement === ChatEntitlement.Pro);1151this.proPlusContextKey.set(state.entitlement === ChatEntitlement.ProPlus);1152this.businessContextKey.set(state.entitlement === ChatEntitlement.Business);1153this.enterpriseContextKey.set(state.entitlement === ChatEntitlement.Enterprise);11541155this.organisationsContextKey.set(state.organisations);1156this.isInternalContextKey.set(Boolean(state.organisations?.some(org => org === 'github' || org === 'microsoft' || org === 'ms-copilot' || org === 'MicrosoftCopilot')));1157this.skuContextKey.set(state.sku);11581159this.hiddenContext.set(!!state.hidden);1160this.laterContext.set(!!state.later);1161this.installedContext.set(!!state.installed);1162this.disabledContext.set(!!state.disabled);1163this.untrustedContext.set(!!state.untrusted);1164this.registeredContext.set(!!state.registered);11651166this.logService.trace(`[chat entitlement context] updateContext(): ${JSON.stringify(state)}`);1167logChatEntitlements(state, this.configurationService, this.telemetryService);11681169this._onDidChange.fire();1170}11711172suspend(): void {1173this.suspendedState = { ...this._state };1174this.updateBarrier = new Barrier();1175}11761177resume(): void {1178this.suspendedState = undefined;1179this.updateBarrier?.open();1180this.updateBarrier = undefined;1181}1182}11831184//#endregion11851186registerSingleton(IChatEntitlementService, ChatEntitlementService, InstantiationType.Eager /* To ensure context keys are set asap */);118711881189