Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/services/chat/common/chatEntitlementService.ts
4779 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import product from '../../../../platform/product/common/product.js';
7
import { Barrier } from '../../../../base/common/async.js';
8
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
9
import { Emitter, Event } from '../../../../base/common/event.js';
10
import { Lazy } from '../../../../base/common/lazy.js';
11
import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
12
import { IRequestContext } from '../../../../base/parts/request/common/request.js';
13
import { localize } from '../../../../nls.js';
14
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
15
import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
16
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
17
import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
18
import { ILogService } from '../../../../platform/log/common/log.js';
19
import { IProductService } from '../../../../platform/product/common/productService.js';
20
import { asText, IRequestService } from '../../../../platform/request/common/request.js';
21
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
22
import { ITelemetryService, TelemetryLevel } from '../../../../platform/telemetry/common/telemetry.js';
23
import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../authentication/common/authentication.js';
24
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
25
import { URI } from '../../../../base/common/uri.js';
26
import Severity from '../../../../base/common/severity.js';
27
import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';
28
import { isWeb } from '../../../../base/common/platform.js';
29
import { ILifecycleService } from '../../lifecycle/common/lifecycle.js';
30
import { Mutable } from '../../../../base/common/types.js';
31
import { distinct } from '../../../../base/common/arrays.js';
32
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
33
import { IObservable, observableFromEvent } from '../../../../base/common/observable.js';
34
35
export namespace ChatEntitlementContextKeys {
36
37
export const Setup = {
38
hidden: new RawContextKey<boolean>('chatSetupHidden', false, true), // True when chat setup is explicitly hidden.
39
installed: new RawContextKey<boolean>('chatSetupInstalled', false, true), // True when the chat extension is installed and enabled.
40
disabled: new RawContextKey<boolean>('chatSetupDisabled', false, true), // True when the chat extension is disabled due to any other reason than workspace trust.
41
untrusted: new RawContextKey<boolean>('chatSetupUntrusted', false, true), // True when the chat extension is disabled due to workspace trust.
42
later: new RawContextKey<boolean>('chatSetupLater', false, true), // True when the user wants to finish setup later.
43
registered: new RawContextKey<boolean>('chatSetupRegistered', false, true) // True when the user has registered as Free or Pro user.
44
};
45
46
export const Entitlement = {
47
signedOut: new RawContextKey<boolean>('chatEntitlementSignedOut', false, true), // True when user is signed out.
48
canSignUp: new RawContextKey<boolean>('chatPlanCanSignUp', false, true), // True when user can sign up to be a chat free user.
49
50
planFree: new RawContextKey<boolean>('chatPlanFree', false, true), // True when user is a chat free user.
51
planPro: new RawContextKey<boolean>('chatPlanPro', false, true), // True when user is a chat pro user.
52
planProPlus: new RawContextKey<boolean>('chatPlanProPlus', false, true), // True when user is a chat pro plus user.
53
planBusiness: new RawContextKey<boolean>('chatPlanBusiness', false, true), // True when user is a chat business user.
54
planEnterprise: new RawContextKey<boolean>('chatPlanEnterprise', false, true), // True when user is a chat enterprise user.
55
56
organisations: new RawContextKey<string[]>('chatEntitlementOrganisations', undefined, true), // The organizations the user belongs to.
57
internal: new RawContextKey<boolean>('chatEntitlementInternal', false, true), // True when user belongs to internal organisation.
58
sku: new RawContextKey<string>('chatEntitlementSku', undefined, true), // The SKU of the user.
59
};
60
61
export const chatQuotaExceeded = new RawContextKey<boolean>('chatQuotaExceeded', false, true);
62
export const completionsQuotaExceeded = new RawContextKey<boolean>('completionsQuotaExceeded', false, true);
63
64
export const chatAnonymous = new RawContextKey<boolean>('chatAnonymous', false, true);
65
}
66
67
export const IChatEntitlementService = createDecorator<IChatEntitlementService>('chatEntitlementService');
68
69
export enum ChatEntitlement {
70
/** Signed out */
71
Unknown = 1,
72
/** Signed in but not yet resolved */
73
Unresolved = 2,
74
/** Signed in and entitled to Free */
75
Available = 3,
76
/** Signed in but not entitled to Free */
77
Unavailable = 4,
78
/** Signed-up to Free */
79
Free = 5,
80
/** Signed-up to Pro */
81
Pro = 6,
82
/** Signed-up to Pro Plus */
83
ProPlus = 7,
84
/** Signed-up to Business */
85
Business = 8,
86
/** Signed-up to Enterprise */
87
Enterprise = 9,
88
}
89
90
export interface IChatSentiment {
91
92
/**
93
* User has Chat installed.
94
*/
95
installed?: boolean;
96
97
/**
98
* User signals no intent in using Chat.
99
*
100
* Note: in contrast to `disabled`, this should not only disable
101
* Chat but also hide all of its UI.
102
*/
103
hidden?: boolean;
104
105
/**
106
* User signals intent to disable Chat.
107
*
108
* Note: in contrast to `hidden`, this should not hide
109
* Chat but but disable its functionality.
110
*/
111
disabled?: boolean;
112
113
/**
114
* Chat is disabled due to missing workspace trust.
115
*
116
* Note: even though this disables Chat, we want to treat it
117
* different from the `disabled` state that is by explicit
118
* user choice.
119
*/
120
untrusted?: boolean;
121
122
/**
123
* User signals intent to use Chat later.
124
*/
125
later?: boolean;
126
127
/**
128
* User has registered as Free or Pro user.
129
*/
130
registered?: boolean;
131
}
132
133
export interface IChatEntitlementService {
134
135
_serviceBrand: undefined;
136
137
readonly onDidChangeEntitlement: Event<void>;
138
139
readonly entitlement: ChatEntitlement;
140
readonly entitlementObs: IObservable<ChatEntitlement>;
141
142
readonly organisations: string[] | undefined;
143
readonly isInternal: boolean;
144
readonly sku: string | undefined;
145
146
readonly onDidChangeQuotaExceeded: Event<void>;
147
readonly onDidChangeQuotaRemaining: Event<void>;
148
149
readonly quotas: IQuotas;
150
151
readonly onDidChangeSentiment: Event<void>;
152
153
readonly sentiment: IChatSentiment;
154
readonly sentimentObs: IObservable<IChatSentiment>;
155
156
// TODO@bpasero eventually this will become enabled by default
157
// and in that case we only need to check on entitlements change
158
// between `unknown` and any other entitlement.
159
readonly onDidChangeAnonymous: Event<void>;
160
readonly anonymous: boolean;
161
readonly anonymousObs: IObservable<boolean>;
162
163
update(token: CancellationToken): Promise<void>;
164
}
165
166
//#region Helper Functions
167
168
/**
169
* Checks the chat entitlements to see if the user falls into the paid category
170
* @param chatEntitlement The chat entitlement to check
171
* @returns Whether or not they are a paid user
172
*/
173
export function isProUser(chatEntitlement: ChatEntitlement): boolean {
174
return chatEntitlement === ChatEntitlement.Pro ||
175
chatEntitlement === ChatEntitlement.ProPlus ||
176
chatEntitlement === ChatEntitlement.Business ||
177
chatEntitlement === ChatEntitlement.Enterprise;
178
}
179
180
//#region Service Implementation
181
182
const defaultChat = {
183
extensionId: product.defaultChatAgent?.extensionId ?? '',
184
chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '',
185
upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '',
186
provider: product.defaultChatAgent?.provider ?? { default: { id: '' }, enterprise: { id: '' } },
187
providerUriSetting: product.defaultChatAgent?.providerUriSetting ?? '',
188
providerScopes: product.defaultChatAgent?.providerScopes ?? [[]],
189
entitlementUrl: product.defaultChatAgent?.entitlementUrl ?? '',
190
entitlementSignupLimitedUrl: product.defaultChatAgent?.entitlementSignupLimitedUrl ?? '',
191
completionsAdvancedSetting: product.defaultChatAgent?.completionsAdvancedSetting ?? '',
192
chatQuotaExceededContext: product.defaultChatAgent?.chatQuotaExceededContext ?? '',
193
completionsQuotaExceededContext: product.defaultChatAgent?.completionsQuotaExceededContext ?? ''
194
};
195
196
interface IChatQuotasAccessor {
197
clearQuotas(): void;
198
acceptQuotas(quotas: IQuotas): void;
199
}
200
201
const CHAT_ALLOW_ANONYMOUS_CONFIGURATION_KEY = 'chat.allowAnonymousAccess';
202
203
function isAnonymous(configurationService: IConfigurationService, entitlement: ChatEntitlement, sentiment: IChatSentiment): boolean {
204
if (configurationService.getValue(CHAT_ALLOW_ANONYMOUS_CONFIGURATION_KEY) !== true) {
205
return false; // only enabled behind an experimental setting
206
}
207
208
if (entitlement !== ChatEntitlement.Unknown) {
209
return false; // only consider signed out users
210
}
211
212
if (sentiment.hidden || sentiment.disabled) {
213
return false; // only consider enabled scenarios
214
}
215
216
return true;
217
}
218
219
type ChatEntitlementClassification = {
220
owner: 'bpasero';
221
comment: 'Provides insight into chat entitlements.';
222
chatHidden: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether chat is hidden or not.' };
223
chatEntitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The current chat entitlement of the user.' };
224
chatAnonymous: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user is anonymously using chat.' };
225
chatRegistered: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user is registered for chat.' };
226
chatDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether chat is disabled or not.' };
227
};
228
type ChatEntitlementEvent = {
229
chatHidden: boolean;
230
chatEntitlement: ChatEntitlement;
231
chatAnonymous: boolean;
232
chatRegistered: boolean;
233
chatDisabled: boolean;
234
};
235
236
function logChatEntitlements(state: IChatEntitlementContextState, configurationService: IConfigurationService, telemetryService: ITelemetryService): void {
237
telemetryService.publicLog2<ChatEntitlementEvent, ChatEntitlementClassification>('chatEntitlements', {
238
chatHidden: Boolean(state.hidden),
239
chatDisabled: Boolean(state.disabled),
240
chatEntitlement: state.entitlement,
241
chatRegistered: Boolean(state.registered),
242
chatAnonymous: isAnonymous(configurationService, state.entitlement, state)
243
});
244
}
245
246
export class ChatEntitlementService extends Disposable implements IChatEntitlementService {
247
248
declare _serviceBrand: undefined;
249
250
readonly context: Lazy<ChatEntitlementContext> | undefined;
251
readonly requests: Lazy<ChatEntitlementRequests> | undefined;
252
253
constructor(
254
@IInstantiationService instantiationService: IInstantiationService,
255
@IProductService productService: IProductService,
256
@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService,
257
@IContextKeyService private readonly contextKeyService: IContextKeyService,
258
@IConfigurationService private readonly configurationService: IConfigurationService,
259
@ITelemetryService private readonly telemetryService: ITelemetryService,
260
) {
261
super();
262
263
this.chatQuotaExceededContextKey = ChatEntitlementContextKeys.chatQuotaExceeded.bindTo(this.contextKeyService);
264
this.completionsQuotaExceededContextKey = ChatEntitlementContextKeys.completionsQuotaExceeded.bindTo(this.contextKeyService);
265
266
this.anonymousContextKey = ChatEntitlementContextKeys.chatAnonymous.bindTo(this.contextKeyService);
267
this.anonymousContextKey.set(this.anonymous);
268
269
this.onDidChangeEntitlement = Event.map(
270
Event.filter(
271
this.contextKeyService.onDidChangeContext, e => e.affectsSome(new Set([
272
ChatEntitlementContextKeys.Entitlement.planPro.key,
273
ChatEntitlementContextKeys.Entitlement.planBusiness.key,
274
ChatEntitlementContextKeys.Entitlement.planEnterprise.key,
275
ChatEntitlementContextKeys.Entitlement.planProPlus.key,
276
ChatEntitlementContextKeys.Entitlement.planFree.key,
277
ChatEntitlementContextKeys.Entitlement.canSignUp.key,
278
ChatEntitlementContextKeys.Entitlement.signedOut.key,
279
ChatEntitlementContextKeys.Entitlement.organisations.key,
280
ChatEntitlementContextKeys.Entitlement.internal.key,
281
ChatEntitlementContextKeys.Entitlement.sku.key
282
])), this._store
283
), () => { }, this._store
284
);
285
this.entitlementObs = observableFromEvent(this.onDidChangeEntitlement, () => this.entitlement);
286
287
this.onDidChangeSentiment = Event.map(
288
Event.filter(
289
this.contextKeyService.onDidChangeContext, e => e.affectsSome(new Set([
290
ChatEntitlementContextKeys.Setup.hidden.key,
291
ChatEntitlementContextKeys.Setup.disabled.key,
292
ChatEntitlementContextKeys.Setup.untrusted.key,
293
ChatEntitlementContextKeys.Setup.installed.key,
294
ChatEntitlementContextKeys.Setup.later.key,
295
ChatEntitlementContextKeys.Setup.registered.key
296
])), this._store
297
), () => { }, this._store
298
);
299
this.sentimentObs = observableFromEvent(this.onDidChangeSentiment, () => this.sentiment);
300
301
if ((isWeb && !environmentService.remoteAuthority)) {
302
ChatEntitlementContextKeys.Setup.hidden.bindTo(this.contextKeyService).set(true); // hide copilot UI on web if unsupported
303
return;
304
}
305
306
if (!productService.defaultChatAgent) {
307
return; // we need a default chat agent configured going forward from here
308
}
309
310
const context = this.context = new Lazy(() => this._register(instantiationService.createInstance(ChatEntitlementContext)));
311
this.requests = new Lazy(() => this._register(instantiationService.createInstance(ChatEntitlementRequests, context.value, {
312
clearQuotas: () => this.clearQuotas(),
313
acceptQuotas: quotas => this.acceptQuotas(quotas)
314
})));
315
316
this.registerListeners();
317
}
318
319
//#region --- Entitlements
320
321
readonly onDidChangeEntitlement: Event<void>;
322
readonly entitlementObs: IObservable<ChatEntitlement>;
323
324
get entitlement(): ChatEntitlement {
325
if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planPro.key) === true) {
326
return ChatEntitlement.Pro;
327
} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planBusiness.key) === true) {
328
return ChatEntitlement.Business;
329
} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planEnterprise.key) === true) {
330
return ChatEntitlement.Enterprise;
331
} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planProPlus.key) === true) {
332
return ChatEntitlement.ProPlus;
333
} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.planFree.key) === true) {
334
return ChatEntitlement.Free;
335
} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.canSignUp.key) === true) {
336
return ChatEntitlement.Available;
337
} else if (this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.signedOut.key) === true) {
338
return ChatEntitlement.Unknown;
339
}
340
341
return ChatEntitlement.Unresolved;
342
}
343
344
get isInternal(): boolean {
345
return this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Entitlement.internal.key) === true;
346
}
347
348
get organisations(): string[] | undefined {
349
return this.contextKeyService.getContextKeyValue<string[]>(ChatEntitlementContextKeys.Entitlement.organisations.key);
350
}
351
352
get sku(): string | undefined {
353
return this.contextKeyService.getContextKeyValue<string>(ChatEntitlementContextKeys.Entitlement.sku.key);
354
}
355
356
//#endregion
357
358
//#region --- Quotas
359
360
private readonly _onDidChangeQuotaExceeded = this._register(new Emitter<void>());
361
readonly onDidChangeQuotaExceeded = this._onDidChangeQuotaExceeded.event;
362
363
private readonly _onDidChangeQuotaRemaining = this._register(new Emitter<void>());
364
readonly onDidChangeQuotaRemaining = this._onDidChangeQuotaRemaining.event;
365
366
private _quotas: IQuotas = {};
367
get quotas() { return this._quotas; }
368
369
private readonly chatQuotaExceededContextKey: IContextKey<boolean>;
370
private readonly completionsQuotaExceededContextKey: IContextKey<boolean>;
371
372
private ExtensionQuotaContextKeys = {
373
chatQuotaExceeded: defaultChat.chatQuotaExceededContext,
374
completionsQuotaExceeded: defaultChat.completionsQuotaExceededContext,
375
};
376
377
private registerListeners(): void {
378
const quotaExceededSet = new Set([this.ExtensionQuotaContextKeys.chatQuotaExceeded, this.ExtensionQuotaContextKeys.completionsQuotaExceeded]);
379
380
const cts = this._register(new MutableDisposable<CancellationTokenSource>());
381
this._register(this.contextKeyService.onDidChangeContext(e => {
382
if (e.affectsSome(quotaExceededSet)) {
383
if (cts.value) {
384
cts.value.cancel();
385
}
386
cts.value = new CancellationTokenSource();
387
this.update(cts.value.token);
388
}
389
}));
390
391
let anonymousUsage = this.anonymous;
392
393
const updateAnonymousUsage = () => {
394
const newAnonymousUsage = this.anonymous;
395
if (newAnonymousUsage !== anonymousUsage) {
396
anonymousUsage = newAnonymousUsage;
397
this.anonymousContextKey.set(newAnonymousUsage);
398
399
if (this.context?.hasValue) {
400
logChatEntitlements(this.context.value.state, this.configurationService, this.telemetryService);
401
}
402
403
this._onDidChangeAnonymous.fire();
404
}
405
};
406
407
this._register(this.configurationService.onDidChangeConfiguration(e => {
408
if (e.affectsConfiguration(CHAT_ALLOW_ANONYMOUS_CONFIGURATION_KEY)) {
409
updateAnonymousUsage();
410
}
411
}));
412
413
this._register(this.onDidChangeEntitlement(() => updateAnonymousUsage()));
414
this._register(this.onDidChangeSentiment(() => updateAnonymousUsage()));
415
}
416
417
acceptQuotas(quotas: IQuotas): void {
418
const oldQuota = this._quotas;
419
this._quotas = quotas;
420
this.updateContextKeys();
421
422
const { changed: chatChanged } = this.compareQuotas(oldQuota.chat, quotas.chat);
423
const { changed: completionsChanged } = this.compareQuotas(oldQuota.completions, quotas.completions);
424
const { changed: premiumChatChanged } = this.compareQuotas(oldQuota.premiumChat, quotas.premiumChat);
425
426
if (chatChanged.exceeded || completionsChanged.exceeded || premiumChatChanged.exceeded) {
427
this._onDidChangeQuotaExceeded.fire();
428
}
429
430
if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining) {
431
this._onDidChangeQuotaRemaining.fire();
432
}
433
}
434
435
private compareQuotas(oldQuota: IQuotaSnapshot | undefined, newQuota: IQuotaSnapshot | undefined): { changed: { exceeded: boolean; remaining: boolean } } {
436
return {
437
changed: {
438
exceeded: (oldQuota?.percentRemaining === 0) !== (newQuota?.percentRemaining === 0),
439
remaining: oldQuota?.percentRemaining !== newQuota?.percentRemaining
440
}
441
};
442
}
443
444
clearQuotas(): void {
445
this.acceptQuotas({});
446
}
447
448
private updateContextKeys(): void {
449
this.chatQuotaExceededContextKey.set(this._quotas.chat?.percentRemaining === 0);
450
this.completionsQuotaExceededContextKey.set(this._quotas.completions?.percentRemaining === 0);
451
}
452
453
//#endregion
454
455
//#region --- Sentiment
456
457
readonly onDidChangeSentiment: Event<void>;
458
readonly sentimentObs: IObservable<IChatSentiment>;
459
460
get sentiment(): IChatSentiment {
461
return {
462
installed: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.installed.key) === true,
463
hidden: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.hidden.key) === true,
464
disabled: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.disabled.key) === true,
465
untrusted: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.untrusted.key) === true,
466
later: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.later.key) === true,
467
registered: this.contextKeyService.getContextKeyValue<boolean>(ChatEntitlementContextKeys.Setup.registered.key) === true
468
};
469
}
470
471
//#endregion
472
473
//region --- Anonymous
474
475
private readonly anonymousContextKey: IContextKey<boolean>;
476
477
private readonly _onDidChangeAnonymous = this._register(new Emitter<void>());
478
readonly onDidChangeAnonymous = this._onDidChangeAnonymous.event;
479
480
readonly anonymousObs = observableFromEvent(this.onDidChangeAnonymous, () => this.anonymous);
481
482
get anonymous(): boolean {
483
return isAnonymous(this.configurationService, this.entitlement, this.sentiment);
484
}
485
486
//#endregion
487
488
async update(token: CancellationToken): Promise<void> {
489
await this.requests?.value.forceResolveEntitlement(undefined, token);
490
}
491
}
492
493
//#endregion
494
495
//#region Chat Entitlement Request Service
496
497
type EntitlementClassification = {
498
tid: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; comment: 'The anonymized analytics id returned by the service'; endpoint: 'GoogleAnalyticsId' };
499
entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating the chat entitlement state' };
500
sku: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The SKU of the chat entitlement' };
501
quotaChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat requests available to the user' };
502
quotaPremiumChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of premium chat requests available to the user' };
503
quotaCompletions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of inline suggestions available to the user' };
504
quotaResetDate: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The date the quota will reset' };
505
owner: 'bpasero';
506
comment: 'Reporting chat entitlements';
507
};
508
509
type EntitlementEvent = {
510
entitlement: ChatEntitlement;
511
tid: string;
512
sku: string | undefined;
513
quotaChat: number | undefined;
514
quotaPremiumChat: number | undefined;
515
quotaCompletions: number | undefined;
516
quotaResetDate: string | undefined;
517
};
518
519
interface IQuotaSnapshotResponse {
520
readonly entitlement: number;
521
readonly overage_count: number;
522
readonly overage_permitted: boolean;
523
readonly percent_remaining: number;
524
readonly remaining: number;
525
readonly unlimited: boolean;
526
}
527
528
interface ILegacyQuotaSnapshotResponse {
529
readonly limited_user_quotas?: {
530
readonly chat: number;
531
readonly completions: number;
532
};
533
readonly monthly_quotas?: {
534
readonly chat: number;
535
readonly completions: number;
536
};
537
}
538
539
interface IEntitlementsResponse extends ILegacyQuotaSnapshotResponse {
540
readonly access_type_sku: string;
541
readonly assigned_date: string;
542
readonly can_signup_for_limited: boolean;
543
readonly chat_enabled: boolean;
544
readonly copilot_plan: string;
545
readonly organization_login_list: string[];
546
readonly analytics_tracking_id: string;
547
readonly limited_user_reset_date?: string; // for Copilot Free
548
readonly quota_reset_date?: string; // for all other Copilot SKUs
549
readonly quota_reset_date_utc?: string; // for all other Copilot SKUs (includes time)
550
readonly quota_snapshots?: {
551
chat?: IQuotaSnapshotResponse;
552
completions?: IQuotaSnapshotResponse;
553
premium_interactions?: IQuotaSnapshotResponse;
554
};
555
}
556
557
interface IEntitlements {
558
readonly entitlement: ChatEntitlement;
559
readonly organisations?: string[];
560
readonly sku?: string;
561
readonly quotas?: IQuotas;
562
}
563
564
export interface IQuotaSnapshot {
565
readonly total: number;
566
567
readonly remaining: number;
568
readonly percentRemaining: number;
569
570
readonly overageEnabled: boolean;
571
readonly overageCount: number;
572
573
readonly unlimited: boolean;
574
}
575
576
interface IQuotas {
577
readonly resetDate?: string;
578
readonly resetDateHasTime?: boolean;
579
580
readonly chat?: IQuotaSnapshot;
581
readonly completions?: IQuotaSnapshot;
582
readonly premiumChat?: IQuotaSnapshot;
583
}
584
585
export class ChatEntitlementRequests extends Disposable {
586
587
static providerId(configurationService: IConfigurationService): string {
588
if (configurationService.getValue<string | undefined>(`${defaultChat.completionsAdvancedSetting}.authProvider`) === defaultChat.provider.enterprise.id) {
589
return defaultChat.provider.enterprise.id;
590
}
591
592
return defaultChat.provider.default.id;
593
}
594
595
private state: IEntitlements;
596
597
private pendingResolveCts = new CancellationTokenSource();
598
private didResolveEntitlements = false;
599
600
constructor(
601
private readonly context: ChatEntitlementContext,
602
private readonly chatQuotasAccessor: IChatQuotasAccessor,
603
@ITelemetryService private readonly telemetryService: ITelemetryService,
604
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
605
@ILogService private readonly logService: ILogService,
606
@IRequestService private readonly requestService: IRequestService,
607
@IDialogService private readonly dialogService: IDialogService,
608
@IOpenerService private readonly openerService: IOpenerService,
609
@IConfigurationService private readonly configurationService: IConfigurationService,
610
@IAuthenticationExtensionsService private readonly authenticationExtensionsService: IAuthenticationExtensionsService,
611
@ILifecycleService private readonly lifecycleService: ILifecycleService,
612
) {
613
super();
614
615
this.state = { entitlement: this.context.state.entitlement };
616
617
this.registerListeners();
618
619
this.resolve();
620
}
621
622
private registerListeners(): void {
623
this._register(this.authenticationService.onDidChangeDeclaredProviders(() => this.resolve()));
624
625
this._register(this.authenticationService.onDidChangeSessions(e => {
626
if (e.providerId === ChatEntitlementRequests.providerId(this.configurationService)) {
627
this.resolve();
628
}
629
}));
630
631
this._register(this.authenticationService.onDidRegisterAuthenticationProvider(e => {
632
if (e.id === ChatEntitlementRequests.providerId(this.configurationService)) {
633
this.resolve();
634
}
635
}));
636
637
this._register(this.authenticationService.onDidUnregisterAuthenticationProvider(e => {
638
if (e.id === ChatEntitlementRequests.providerId(this.configurationService)) {
639
this.resolve();
640
}
641
}));
642
643
this._register(this.context.onDidChange(() => {
644
if (!this.context.state.installed || this.context.state.disabled || this.context.state.entitlement === ChatEntitlement.Unknown) {
645
// When the extension is not installed, disabled or the user is not entitled
646
// make sure to clear quotas so that any indicators are also gone
647
this.state = { entitlement: this.state.entitlement, quotas: undefined };
648
this.chatQuotasAccessor.clearQuotas();
649
}
650
}));
651
}
652
653
private async resolve(): Promise<void> {
654
this.pendingResolveCts.dispose(true);
655
const cts = this.pendingResolveCts = new CancellationTokenSource();
656
657
const session = await this.findMatchingProviderSession(cts.token);
658
if (cts.token.isCancellationRequested) {
659
return;
660
}
661
662
// Immediately signal whether we have a session or not
663
let state: IEntitlements | undefined = undefined;
664
if (session) {
665
// Do not overwrite any state we have already
666
if (this.state.entitlement === ChatEntitlement.Unknown) {
667
state = { entitlement: ChatEntitlement.Unresolved };
668
}
669
} else {
670
this.didResolveEntitlements = false; // reset so that we resolve entitlements fresh when signed in again
671
state = { entitlement: ChatEntitlement.Unknown };
672
}
673
if (state) {
674
this.update(state);
675
}
676
677
if (session && !this.didResolveEntitlements) {
678
// Afterwards resolve entitlement with a network request
679
// but only unless it was not already resolved before.
680
await this.resolveEntitlement(session, cts.token);
681
}
682
}
683
684
private async findMatchingProviderSession(token: CancellationToken): Promise<AuthenticationSession[] | undefined> {
685
const sessions = await this.doGetSessions(ChatEntitlementRequests.providerId(this.configurationService));
686
if (token.isCancellationRequested) {
687
return undefined;
688
}
689
690
const matchingSessions = new Set<AuthenticationSession>();
691
for (const session of sessions) {
692
for (const scopes of defaultChat.providerScopes) {
693
if (this.includesScopes(session.scopes, scopes)) {
694
matchingSessions.add(session);
695
}
696
}
697
}
698
699
// We intentionally want to return an array of matching sessions and
700
// not just the first, because it is possible that a matching session
701
// has an expired token. As such, we want to try them all until we
702
// succeeded with the request.
703
return matchingSessions.size > 0 ? Array.from(matchingSessions) : undefined;
704
}
705
706
private async doGetSessions(providerId: string): Promise<readonly AuthenticationSession[]> {
707
const preferredAccountName = this.authenticationExtensionsService.getAccountPreference(defaultChat.chatExtensionId, providerId) ?? this.authenticationExtensionsService.getAccountPreference(defaultChat.extensionId, providerId);
708
let preferredAccount: AuthenticationSessionAccount | undefined;
709
for (const account of await this.authenticationService.getAccounts(providerId)) {
710
if (account.label === preferredAccountName) {
711
preferredAccount = account;
712
break;
713
}
714
}
715
716
try {
717
return await this.authenticationService.getSessions(providerId, undefined, { account: preferredAccount });
718
} catch (error) {
719
// ignore - errors can throw if a provider is not registered
720
}
721
722
return [];
723
}
724
725
private includesScopes(scopes: ReadonlyArray<string>, expectedScopes: string[]): boolean {
726
return expectedScopes.every(scope => scopes.includes(scope));
727
}
728
729
private async resolveEntitlement(sessions: AuthenticationSession[], token: CancellationToken): Promise<IEntitlements | undefined> {
730
const entitlements = await this.doResolveEntitlement(sessions, token);
731
if (typeof entitlements?.entitlement === 'number' && !token.isCancellationRequested) {
732
this.didResolveEntitlements = true;
733
this.update(entitlements);
734
}
735
736
return entitlements;
737
}
738
739
private async doResolveEntitlement(sessions: AuthenticationSession[], token: CancellationToken): Promise<IEntitlements | undefined> {
740
if (token.isCancellationRequested) {
741
return undefined;
742
}
743
744
const response = await this.request(this.getEntitlementUrl(), 'GET', undefined, sessions, token);
745
if (token.isCancellationRequested) {
746
return undefined;
747
}
748
749
if (!response) {
750
this.logService.trace('[chat entitlement]: no response');
751
return { entitlement: ChatEntitlement.Unresolved };
752
}
753
754
if (response.res.statusCode && response.res.statusCode !== 200) {
755
this.logService.trace(`[chat entitlement]: unexpected status code ${response.res.statusCode}`);
756
return (
757
response.res.statusCode === 401 || // oauth token being unavailable (expired/revoked)
758
response.res.statusCode === 404 // missing scopes/permissions, service pretends the endpoint doesn't exist
759
) ? { entitlement: ChatEntitlement.Unknown /* treat as signed out */ } : { entitlement: ChatEntitlement.Unresolved };
760
}
761
762
let responseText: string | null = null;
763
try {
764
responseText = await asText(response);
765
} catch (error) {
766
// ignore - handled below
767
}
768
if (token.isCancellationRequested) {
769
return undefined;
770
}
771
772
if (!responseText) {
773
this.logService.trace('[chat entitlement]: response has no content');
774
return { entitlement: ChatEntitlement.Unresolved };
775
}
776
777
let entitlementsResponse: IEntitlementsResponse;
778
try {
779
entitlementsResponse = JSON.parse(responseText);
780
this.logService.trace(`[chat entitlement]: parsed result is ${JSON.stringify(entitlementsResponse)}`);
781
} catch (err) {
782
this.logService.trace(`[chat entitlement]: error parsing response (${err})`);
783
return { entitlement: ChatEntitlement.Unresolved };
784
}
785
786
let entitlement: ChatEntitlement;
787
if (entitlementsResponse.access_type_sku === 'free_limited_copilot') {
788
entitlement = ChatEntitlement.Free;
789
} else if (entitlementsResponse.can_signup_for_limited) {
790
entitlement = ChatEntitlement.Available;
791
} else if (entitlementsResponse.copilot_plan === 'individual') {
792
entitlement = ChatEntitlement.Pro;
793
} else if (entitlementsResponse.copilot_plan === 'individual_pro') {
794
entitlement = ChatEntitlement.ProPlus;
795
} else if (entitlementsResponse.copilot_plan === 'business') {
796
entitlement = ChatEntitlement.Business;
797
} else if (entitlementsResponse.copilot_plan === 'enterprise') {
798
entitlement = ChatEntitlement.Enterprise;
799
} else if (entitlementsResponse.chat_enabled) {
800
// 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 break
801
entitlement = ChatEntitlement.Pro;
802
} else {
803
entitlement = ChatEntitlement.Unavailable;
804
}
805
806
const entitlements: IEntitlements = {
807
entitlement,
808
organisations: entitlementsResponse.organization_login_list,
809
quotas: this.toQuotas(entitlementsResponse),
810
sku: entitlementsResponse.access_type_sku
811
};
812
813
this.logService.trace(`[chat entitlement]: resolved to ${entitlements.entitlement}, quotas: ${JSON.stringify(entitlements.quotas)}`);
814
this.telemetryService.publicLog2<EntitlementEvent, EntitlementClassification>('chatInstallEntitlement', {
815
entitlement: entitlements.entitlement,
816
tid: entitlementsResponse.analytics_tracking_id,
817
sku: entitlements.sku,
818
quotaChat: entitlements.quotas?.chat?.remaining,
819
quotaPremiumChat: entitlements.quotas?.premiumChat?.remaining,
820
quotaCompletions: entitlements.quotas?.completions?.remaining,
821
quotaResetDate: entitlements.quotas?.resetDate
822
});
823
824
return entitlements;
825
}
826
827
private getEntitlementUrl(): string {
828
if (ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider.enterprise.id) {
829
try {
830
const enterpriseUrl = new URL(this.configurationService.getValue(defaultChat.providerUriSetting));
831
return `${enterpriseUrl.protocol}//api.${enterpriseUrl.hostname}${enterpriseUrl.port ? ':' + enterpriseUrl.port : ''}/copilot_internal/user`;
832
} catch (error) {
833
this.logService.error(error);
834
}
835
}
836
837
return defaultChat.entitlementUrl;
838
}
839
840
private toQuotas(response: IEntitlementsResponse): IQuotas {
841
const quotas: Mutable<IQuotas> = {
842
resetDate: response.quota_reset_date_utc ?? response.quota_reset_date ?? response.limited_user_reset_date,
843
resetDateHasTime: typeof response.quota_reset_date_utc === 'string',
844
};
845
846
// Legacy Free SKU Quota
847
if (response.monthly_quotas?.chat && typeof response.limited_user_quotas?.chat === 'number') {
848
quotas.chat = {
849
total: response.monthly_quotas.chat,
850
remaining: response.limited_user_quotas.chat,
851
percentRemaining: Math.min(100, Math.max(0, (response.limited_user_quotas.chat / response.monthly_quotas.chat) * 100)),
852
overageEnabled: false,
853
overageCount: 0,
854
unlimited: false
855
};
856
}
857
858
if (response.monthly_quotas?.completions && typeof response.limited_user_quotas?.completions === 'number') {
859
quotas.completions = {
860
total: response.monthly_quotas.completions,
861
remaining: response.limited_user_quotas.completions,
862
percentRemaining: Math.min(100, Math.max(0, (response.limited_user_quotas.completions / response.monthly_quotas.completions) * 100)),
863
overageEnabled: false,
864
overageCount: 0,
865
unlimited: false
866
};
867
}
868
869
// New Quota Snapshot
870
if (response.quota_snapshots) {
871
for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) {
872
const rawQuotaSnapshot = response.quota_snapshots[quotaType];
873
if (!rawQuotaSnapshot) {
874
continue;
875
}
876
const quotaSnapshot: IQuotaSnapshot = {
877
total: rawQuotaSnapshot.entitlement,
878
remaining: rawQuotaSnapshot.remaining,
879
percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)),
880
overageEnabled: rawQuotaSnapshot.overage_permitted,
881
overageCount: rawQuotaSnapshot.overage_count,
882
unlimited: rawQuotaSnapshot.unlimited
883
};
884
885
switch (quotaType) {
886
case 'chat':
887
quotas.chat = quotaSnapshot;
888
break;
889
case 'completions':
890
quotas.completions = quotaSnapshot;
891
break;
892
case 'premium_interactions':
893
quotas.premiumChat = quotaSnapshot;
894
break;
895
}
896
}
897
}
898
899
return quotas;
900
}
901
902
private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined>;
903
private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined>;
904
private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise<IRequestContext | undefined> {
905
let lastRequest: IRequestContext | undefined;
906
907
for (const session of sessions) {
908
if (token.isCancellationRequested) {
909
return lastRequest;
910
}
911
912
try {
913
const response = await this.requestService.request({
914
type,
915
url,
916
data: type === 'POST' ? JSON.stringify(body) : undefined,
917
disableCache: true,
918
headers: {
919
'Authorization': `Bearer ${session.accessToken}`
920
}
921
}, token);
922
923
const status = response.res.statusCode;
924
if (status && status !== 200) {
925
lastRequest = response;
926
continue; // try next session
927
}
928
929
return response;
930
} catch (error) {
931
if (!token.isCancellationRequested) {
932
this.logService.error(`[chat entitlement] request: error ${error}`);
933
}
934
}
935
}
936
937
return lastRequest;
938
}
939
940
private update(state: IEntitlements): void {
941
this.state = state;
942
943
this.context.update({ entitlement: this.state.entitlement, organisations: this.state.organisations, sku: this.state.sku });
944
945
if (state.quotas) {
946
this.chatQuotasAccessor.acceptQuotas(state.quotas);
947
}
948
}
949
950
async forceResolveEntitlement(sessions: AuthenticationSession[] | undefined, token = CancellationToken.None): Promise<IEntitlements | undefined> {
951
if (!sessions) {
952
sessions = await this.findMatchingProviderSession(token);
953
}
954
955
if (!sessions || sessions.length === 0) {
956
return undefined;
957
}
958
959
return this.resolveEntitlement(sessions, token);
960
}
961
962
async signUpFree(sessions: AuthenticationSession[]): Promise<true /* signed up */ | false /* already signed up */ | { errorCode: number } /* error */> {
963
const body = {
964
restricted_telemetry: this.telemetryService.telemetryLevel === TelemetryLevel.NONE ? 'disabled' : 'enabled',
965
public_code_suggestions: 'enabled'
966
};
967
968
const response = await this.request(defaultChat.entitlementSignupLimitedUrl, 'POST', body, sessions, CancellationToken.None);
969
if (!response) {
970
const retry = await this.onUnknownSignUpError(localize('signUpNoResponseError', "No response received."), '[chat entitlement] sign-up: no response');
971
return retry ? this.signUpFree(sessions) : { errorCode: 1 };
972
}
973
974
if (response.res.statusCode && response.res.statusCode !== 200) {
975
if (response.res.statusCode === 422) {
976
try {
977
const responseText = await asText(response);
978
if (responseText) {
979
const responseError: { message: string } = JSON.parse(responseText);
980
if (typeof responseError.message === 'string' && responseError.message) {
981
this.onUnprocessableSignUpError(`[chat entitlement] sign-up: unprocessable entity (${responseError.message})`, responseError.message);
982
return { errorCode: response.res.statusCode };
983
}
984
}
985
} catch (error) {
986
// ignore - handled below
987
}
988
}
989
const retry = await this.onUnknownSignUpError(localize('signUpUnexpectedStatusError', "Unexpected status code {0}.", response.res.statusCode), `[chat entitlement] sign-up: unexpected status code ${response.res.statusCode}`);
990
return retry ? this.signUpFree(sessions) : { errorCode: response.res.statusCode };
991
}
992
993
let responseText: string | null = null;
994
try {
995
responseText = await asText(response);
996
} catch (error) {
997
// ignore - handled below
998
}
999
1000
if (!responseText) {
1001
const retry = await this.onUnknownSignUpError(localize('signUpNoResponseContentsError', "Response has no contents."), '[chat entitlement] sign-up: response has no content');
1002
return retry ? this.signUpFree(sessions) : { errorCode: 2 };
1003
}
1004
1005
let parsedResult: { subscribed: boolean } | undefined = undefined;
1006
try {
1007
parsedResult = JSON.parse(responseText);
1008
this.logService.trace(`[chat entitlement] sign-up: response is ${responseText}`);
1009
} catch (err) {
1010
const retry = await this.onUnknownSignUpError(localize('signUpInvalidResponseError', "Invalid response contents."), `[chat entitlement] sign-up: error parsing response (${err})`);
1011
return retry ? this.signUpFree(sessions) : { errorCode: 3 };
1012
}
1013
1014
// We have made it this far, so the user either did sign-up or was signed-up already.
1015
// That is, because the endpoint throws in all other case according to Patrick.
1016
this.update({ entitlement: ChatEntitlement.Free });
1017
1018
return Boolean(parsedResult?.subscribed);
1019
}
1020
1021
private async onUnknownSignUpError(detail: string, logMessage: string): Promise<boolean> {
1022
this.logService.error(logMessage);
1023
1024
if (!this.lifecycleService.willShutdown) {
1025
const { confirmed } = await this.dialogService.confirm({
1026
type: Severity.Error,
1027
message: localize('unknownSignUpError', "An error occurred while signing up for the GitHub Copilot Free plan. Would you like to try again?"),
1028
detail,
1029
primaryButton: localize('retry', "Retry")
1030
});
1031
1032
return confirmed;
1033
}
1034
1035
return false;
1036
}
1037
1038
private onUnprocessableSignUpError(logMessage: string, logDetails: string): void {
1039
this.logService.error(logMessage);
1040
1041
if (!this.lifecycleService.willShutdown) {
1042
this.dialogService.prompt({
1043
type: Severity.Error,
1044
message: localize('unprocessableSignUpError', "An error occurred while signing up for the GitHub Copilot Free plan."),
1045
detail: logDetails,
1046
buttons: [
1047
{
1048
label: localize('ok', "OK"),
1049
run: () => { /* noop */ }
1050
},
1051
{
1052
label: localize('learnMore', "Learn More"),
1053
run: () => this.openerService.open(URI.parse(defaultChat.upgradePlanUrl))
1054
}
1055
]
1056
});
1057
}
1058
}
1059
1060
async signIn(options?: { useSocialProvider?: string; additionalScopes?: readonly string[] }) {
1061
const providerId = ChatEntitlementRequests.providerId(this.configurationService);
1062
1063
const scopes = options?.additionalScopes ? distinct([...defaultChat.providerScopes[0], ...options.additionalScopes]) : defaultChat.providerScopes[0];
1064
const session = await this.authenticationService.createSession(
1065
providerId,
1066
scopes,
1067
{
1068
extraAuthorizeParameters: { get_started_with: 'copilot-vscode' },
1069
provider: options?.useSocialProvider
1070
});
1071
1072
this.authenticationExtensionsService.updateAccountPreference(defaultChat.extensionId, providerId, session.account);
1073
this.authenticationExtensionsService.updateAccountPreference(defaultChat.chatExtensionId, providerId, session.account);
1074
1075
const entitlements = await this.forceResolveEntitlement([session]);
1076
1077
return { session, entitlements };
1078
}
1079
1080
override dispose(): void {
1081
this.pendingResolveCts.dispose(true);
1082
1083
super.dispose();
1084
}
1085
}
1086
1087
//#endregion
1088
1089
//#region Context
1090
1091
export interface IChatEntitlementContextState extends IChatSentiment {
1092
1093
/**
1094
* Users last known or resolved entitlement.
1095
*/
1096
entitlement: ChatEntitlement;
1097
1098
/**
1099
* User's last known or resolved raw SKU type.
1100
*/
1101
sku: string | undefined;
1102
1103
/**
1104
* User's last known or resolved organisations.
1105
*/
1106
organisations: string[] | undefined;
1107
1108
/**
1109
* User is or was a registered Chat user.
1110
*/
1111
registered?: boolean;
1112
}
1113
1114
export class ChatEntitlementContext extends Disposable {
1115
1116
private static readonly CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY = 'chat.setupContext';
1117
1118
private static readonly CHAT_DISABLED_CONFIGURATION_KEY = 'chat.disableAIFeatures';
1119
1120
private readonly canSignUpContextKey: IContextKey<boolean>;
1121
private readonly signedOutContextKey: IContextKey<boolean>;
1122
1123
private readonly freeContextKey: IContextKey<boolean>;
1124
private readonly proContextKey: IContextKey<boolean>;
1125
private readonly proPlusContextKey: IContextKey<boolean>;
1126
private readonly businessContextKey: IContextKey<boolean>;
1127
private readonly enterpriseContextKey: IContextKey<boolean>;
1128
1129
private readonly organisationsContextKey: IContextKey<string[] | undefined>;
1130
private readonly isInternalContextKey: IContextKey<boolean>;
1131
private readonly skuContextKey: IContextKey<string | undefined>;
1132
1133
private readonly hiddenContext: IContextKey<boolean>;
1134
private readonly laterContext: IContextKey<boolean>;
1135
private readonly installedContext: IContextKey<boolean>;
1136
private readonly disabledContext: IContextKey<boolean>;
1137
private readonly untrustedContext: IContextKey<boolean>;
1138
private readonly registeredContext: IContextKey<boolean>;
1139
1140
private _state: IChatEntitlementContextState;
1141
private suspendedState: IChatEntitlementContextState | undefined = undefined;
1142
get state(): IChatEntitlementContextState { return this.withConfiguration(this.suspendedState ?? this._state); }
1143
1144
private readonly _onDidChange = this._register(new Emitter<void>());
1145
readonly onDidChange = this._onDidChange.event;
1146
1147
private updateBarrier: Barrier | undefined = undefined;
1148
1149
constructor(
1150
@IContextKeyService contextKeyService: IContextKeyService,
1151
@IStorageService private readonly storageService: IStorageService,
1152
@ILogService private readonly logService: ILogService,
1153
@IConfigurationService private readonly configurationService: IConfigurationService,
1154
@ITelemetryService private readonly telemetryService: ITelemetryService
1155
) {
1156
super();
1157
1158
this.canSignUpContextKey = ChatEntitlementContextKeys.Entitlement.canSignUp.bindTo(contextKeyService);
1159
this.signedOutContextKey = ChatEntitlementContextKeys.Entitlement.signedOut.bindTo(contextKeyService);
1160
1161
this.freeContextKey = ChatEntitlementContextKeys.Entitlement.planFree.bindTo(contextKeyService);
1162
this.proContextKey = ChatEntitlementContextKeys.Entitlement.planPro.bindTo(contextKeyService);
1163
this.proPlusContextKey = ChatEntitlementContextKeys.Entitlement.planProPlus.bindTo(contextKeyService);
1164
this.businessContextKey = ChatEntitlementContextKeys.Entitlement.planBusiness.bindTo(contextKeyService);
1165
this.enterpriseContextKey = ChatEntitlementContextKeys.Entitlement.planEnterprise.bindTo(contextKeyService);
1166
1167
this.organisationsContextKey = ChatEntitlementContextKeys.Entitlement.organisations.bindTo(contextKeyService);
1168
this.isInternalContextKey = ChatEntitlementContextKeys.Entitlement.internal.bindTo(contextKeyService);
1169
this.skuContextKey = ChatEntitlementContextKeys.Entitlement.sku.bindTo(contextKeyService);
1170
1171
this.hiddenContext = ChatEntitlementContextKeys.Setup.hidden.bindTo(contextKeyService);
1172
this.laterContext = ChatEntitlementContextKeys.Setup.later.bindTo(contextKeyService);
1173
this.installedContext = ChatEntitlementContextKeys.Setup.installed.bindTo(contextKeyService);
1174
this.disabledContext = ChatEntitlementContextKeys.Setup.disabled.bindTo(contextKeyService);
1175
this.untrustedContext = ChatEntitlementContextKeys.Setup.untrusted.bindTo(contextKeyService);
1176
this.registeredContext = ChatEntitlementContextKeys.Setup.registered.bindTo(contextKeyService);
1177
1178
this._state = this.storageService.getObject<IChatEntitlementContextState>(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, StorageScope.PROFILE) ?? { entitlement: ChatEntitlement.Unknown, organisations: undefined, sku: undefined };
1179
1180
this.updateContextSync();
1181
1182
this.registerListeners();
1183
}
1184
1185
private registerListeners(): void {
1186
this._register(this.configurationService.onDidChangeConfiguration(e => {
1187
if (e.affectsConfiguration(ChatEntitlementContext.CHAT_DISABLED_CONFIGURATION_KEY)) {
1188
this.updateContext();
1189
}
1190
}));
1191
}
1192
1193
private withConfiguration(state: IChatEntitlementContextState): IChatEntitlementContextState {
1194
if (this.configurationService.getValue(ChatEntitlementContext.CHAT_DISABLED_CONFIGURATION_KEY) === true) {
1195
return {
1196
...state,
1197
hidden: true // Setting always wins: if AI is disabled, set `hidden: true`
1198
};
1199
}
1200
1201
return state;
1202
}
1203
1204
update(context: { installed: boolean; disabled: boolean; untrusted: boolean }): Promise<void>;
1205
update(context: { hidden: false }): Promise<void>; // legacy UI state from before we had a setting to hide, keep around to still support users who used this
1206
update(context: { later: boolean }): Promise<void>;
1207
update(context: { entitlement: ChatEntitlement; organisations: string[] | undefined; sku: string | undefined }): Promise<void>;
1208
async update(context: { installed?: boolean; disabled?: boolean; untrusted?: boolean; hidden?: false; later?: boolean; entitlement?: ChatEntitlement; organisations?: string[]; sku?: string }): Promise<void> {
1209
this.logService.trace(`[chat entitlement context] update(): ${JSON.stringify(context)}`);
1210
1211
const oldState = JSON.stringify(this._state);
1212
1213
if (typeof context.installed === 'boolean' && typeof context.disabled === 'boolean' && typeof context.untrusted === 'boolean') {
1214
this._state.installed = context.installed;
1215
this._state.disabled = context.disabled;
1216
this._state.untrusted = context.untrusted;
1217
1218
if (context.installed && !context.disabled) {
1219
context.hidden = false; // treat this as a sign to make Chat visible again in case it is hidden
1220
}
1221
}
1222
1223
if (typeof context.hidden === 'boolean') {
1224
this._state.hidden = context.hidden;
1225
}
1226
1227
if (typeof context.later === 'boolean') {
1228
this._state.later = context.later;
1229
}
1230
1231
if (typeof context.entitlement === 'number') {
1232
this._state.entitlement = context.entitlement;
1233
this._state.organisations = context.organisations;
1234
this._state.sku = context.sku;
1235
1236
if (this._state.entitlement === ChatEntitlement.Free || isProUser(this._state.entitlement)) {
1237
this._state.registered = true;
1238
} else if (this._state.entitlement === ChatEntitlement.Available) {
1239
this._state.registered = false; // only reset when signed-in user can sign-up for free
1240
}
1241
}
1242
1243
if (isAnonymous(this.configurationService, this._state.entitlement, this._state)) {
1244
this._state.sku = 'no_auth_limited_copilot'; // no-auth users have a fixed SKU
1245
}
1246
1247
if (oldState === JSON.stringify(this._state)) {
1248
return; // state did not change
1249
}
1250
1251
this.storageService.store(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, {
1252
...this._state,
1253
later: undefined // do not persist this across restarts for now
1254
}, StorageScope.PROFILE, StorageTarget.MACHINE);
1255
1256
return this.updateContext();
1257
}
1258
1259
private async updateContext(): Promise<void> {
1260
await this.updateBarrier?.wait();
1261
1262
this.updateContextSync();
1263
}
1264
1265
private updateContextSync(): void {
1266
const state = this.withConfiguration(this._state);
1267
1268
this.signedOutContextKey.set(state.entitlement === ChatEntitlement.Unknown);
1269
this.canSignUpContextKey.set(state.entitlement === ChatEntitlement.Available);
1270
1271
this.freeContextKey.set(state.entitlement === ChatEntitlement.Free);
1272
this.proContextKey.set(state.entitlement === ChatEntitlement.Pro);
1273
this.proPlusContextKey.set(state.entitlement === ChatEntitlement.ProPlus);
1274
this.businessContextKey.set(state.entitlement === ChatEntitlement.Business);
1275
this.enterpriseContextKey.set(state.entitlement === ChatEntitlement.Enterprise);
1276
1277
this.organisationsContextKey.set(state.organisations);
1278
this.isInternalContextKey.set(Boolean(state.organisations?.some(org => org === 'github' || org === 'microsoft' || org === 'ms-copilot' || org === 'MicrosoftCopilot')));
1279
this.skuContextKey.set(state.sku);
1280
1281
this.hiddenContext.set(!!state.hidden);
1282
this.laterContext.set(!!state.later);
1283
this.installedContext.set(!!state.installed);
1284
this.disabledContext.set(!!state.disabled);
1285
this.untrustedContext.set(!!state.untrusted);
1286
this.registeredContext.set(!!state.registered);
1287
1288
this.logService.trace(`[chat entitlement context] updateContext(): ${JSON.stringify(state)}`);
1289
logChatEntitlements(state, this.configurationService, this.telemetryService);
1290
1291
this._onDidChange.fire();
1292
}
1293
1294
suspend(): void {
1295
this.suspendedState = { ...this._state };
1296
this.updateBarrier = new Barrier();
1297
}
1298
1299
resume(): void {
1300
this.suspendedState = undefined;
1301
this.updateBarrier?.open();
1302
this.updateBarrier = undefined;
1303
}
1304
}
1305
1306
//#endregion
1307
1308
registerSingleton(IChatEntitlementService, ChatEntitlementService, InstantiationType.Eager /* To ensure context keys are set asap */);
1309
1310