From 6b79dafbb11995fd5f85d7fd6d8ea8a0fb9b4b70 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 23 Apr 2026 12:26:03 -0700 Subject: [PATCH 1/8] Enable BYOT without authentication --- .../src/extension/byok/common/byokProvider.ts | 13 ---- .../byok/vscode-node/byokContribution.ts | 59 +++++++------------ .../vscode-node/contextKeys.contribution.ts | 9 +-- .../experiments/agentTitleBarStatusWidget.ts | 2 +- .../chatManagement/chatModelsWidget.ts | 2 +- .../browser/chatSetup/chatSetupProviders.ts | 3 +- .../chat/browser/chatSetup/chatSetupRunner.ts | 2 +- .../browser/chatStatus/chatStatusDashboard.ts | 4 +- .../browser/chatStatus/chatStatusEntry.ts | 2 +- .../contrib/chat/browser/chatTipService.ts | 2 +- .../chatContentParts/chatTipContentPart.ts | 2 +- .../common/gettingStartedContent.ts | 2 +- 12 files changed, 35 insertions(+), 67 deletions(-) diff --git a/extensions/copilot/src/extension/byok/common/byokProvider.ts b/extensions/copilot/src/extension/byok/common/byokProvider.ts index 54237bd90e507..f4aa7bc5a6885 100644 --- a/extensions/copilot/src/extension/byok/common/byokProvider.ts +++ b/extensions/copilot/src/extension/byok/common/byokProvider.ts @@ -3,10 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import type { Disposable, LanguageModelChatInformation, LanguageModelDataPart, LanguageModelTextPart, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolResultPart } from 'vscode'; -import { CopilotToken } from '../../../platform/authentication/common/copilotToken'; -import { ICAPIClientService } from '../../../platform/endpoint/common/capiClient'; import { EndpointEditToolName, IChatModelInformation, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; -import { isScenarioAutomation } from '../../../platform/env/common/envService'; import { TokenizerType } from '../../../util/common/tokenizer'; export const enum BYOKAuthType { @@ -155,16 +152,6 @@ export function byokKnownModelToAPIInfo(providerName: string, id: string, capabi }; } -export function isBYOKEnabled(copilotToken: Omit, capiClientService: ICAPIClientService): boolean { - if (isScenarioAutomation) { - return true; - } - - const isGHE = capiClientService.dotcomAPIURL !== 'https://api.github.com'; - const byokAllowed = (copilotToken.isInternal || copilotToken.isIndividual || copilotToken.isClientBYOKEnabled()) && !isGHE; - return byokAllowed; -} - /** * Result of handling an API key update operation. */ diff --git a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts index 3cf6f8e7bce99..a8a5bada6985e 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts @@ -3,14 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { LanguageModelChatInformation, LanguageModelChatProvider, lm } from 'vscode'; -import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; -import { ICAPIClientService } from '../../../platform/endpoint/common/capiClient'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { ILogService } from '../../../platform/log/common/logService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { BYOKKnownModels, isBYOKEnabled } from '../../byok/common/byokProvider'; +import { BYOKKnownModels } from '../../byok/common/byokProvider'; import { IExtensionContribution } from '../../common/contributions'; import { AnthropicLMProvider } from './anthropicProvider'; import { AzureBYOKModelProvider } from './azureProvider'; @@ -32,50 +30,37 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { constructor( @IFetcherService private readonly _fetcherService: IFetcherService, @ILogService private readonly _logService: ILogService, - @ICAPIClientService private readonly _capiClientService: ICAPIClientService, @IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext, - @IAuthenticationService authService: IAuthenticationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); this._byokStorageService = new BYOKStorageService(extensionContext); - this._authChange(authService, this._instantiationService); - - this._register(authService.onDidAuthenticationChange(() => { - this._authChange(authService, this._instantiationService); - })); + this._registerProviders(); } - private async _authChange(authService: IAuthenticationService, instantiationService: IInstantiationService) { - const byokEnabled = authService.copilotToken && isBYOKEnabled(authService.copilotToken, this._capiClientService); - - if (!byokEnabled && this._byokProvidersRegistered) { - this._logService.info('BYOK: Disabling BYOK providers due to account change.'); - this._byokRegistrations.clear(); - this._providers.clear(); - this._byokProvidersRegistered = false; + private async _registerProviders() { + if (this._byokProvidersRegistered) { return; } - if (byokEnabled && !this._byokProvidersRegistered) { - this._byokProvidersRegistered = true; - // Update known models list from CDN so all providers have the same list - const knownModels = await this.fetchKnownModelList(this._fetcherService); - if (this._store.isDisposed) { - return; - } - this._providers.set(OllamaLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OllamaLMProvider, this._byokStorageService)); - this._providers.set(AnthropicLMProvider.providerName.toLowerCase(), instantiationService.createInstance(AnthropicLMProvider, knownModels[AnthropicLMProvider.providerName], this._byokStorageService)); - this._providers.set(GeminiNativeBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(GeminiNativeBYOKLMProvider, knownModels[GeminiNativeBYOKLMProvider.providerName], this._byokStorageService)); - this._providers.set(XAIBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(XAIBYOKLMProvider, knownModels[XAIBYOKLMProvider.providerName], this._byokStorageService)); - this._providers.set(OAIBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OAIBYOKLMProvider, knownModels[OAIBYOKLMProvider.providerName], this._byokStorageService)); - this._providers.set(OpenRouterLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OpenRouterLMProvider, this._byokStorageService)); - this._providers.set(AzureBYOKModelProvider.providerName.toLowerCase(), instantiationService.createInstance(AzureBYOKModelProvider, this._byokStorageService)); - this._providers.set(CustomOAIBYOKModelProvider.providerName.toLowerCase(), instantiationService.createInstance(CustomOAIBYOKModelProvider, this._byokStorageService)); + this._byokProvidersRegistered = true; + const instantiationService = this._instantiationService; + // Update known models list from CDN so all providers have the same list + const knownModels = await this.fetchKnownModelList(this._fetcherService); + if (this._store.isDisposed) { + return; + } + this._providers.set(OllamaLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OllamaLMProvider, this._byokStorageService)); + this._providers.set(AnthropicLMProvider.providerName.toLowerCase(), instantiationService.createInstance(AnthropicLMProvider, knownModels[AnthropicLMProvider.providerName], this._byokStorageService)); + this._providers.set(GeminiNativeBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(GeminiNativeBYOKLMProvider, knownModels[GeminiNativeBYOKLMProvider.providerName], this._byokStorageService)); + this._providers.set(XAIBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(XAIBYOKLMProvider, knownModels[XAIBYOKLMProvider.providerName], this._byokStorageService)); + this._providers.set(OAIBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OAIBYOKLMProvider, knownModels[OAIBYOKLMProvider.providerName], this._byokStorageService)); + this._providers.set(OpenRouterLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OpenRouterLMProvider, this._byokStorageService)); + this._providers.set(AzureBYOKModelProvider.providerName.toLowerCase(), instantiationService.createInstance(AzureBYOKModelProvider, this._byokStorageService)); + this._providers.set(CustomOAIBYOKModelProvider.providerName.toLowerCase(), instantiationService.createInstance(CustomOAIBYOKModelProvider, this._byokStorageService)); - for (const [providerName, provider] of this._providers) { - this._byokRegistrations.add(lm.registerLanguageModelChatProvider(providerName, provider)); - } + for (const [providerName, provider] of this._providers) { + this._byokRegistrations.add(lm.registerLanguageModelChatProvider(providerName, provider)); } } private async fetchKnownModelList(fetcherService: IFetcherService): Promise> { @@ -92,4 +77,4 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { this._logService.info('BYOK: Copilot Chat known models list fetched successfully.'); return knownModels; } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts index 876efa5244a90..54b655baf3ee8 100644 --- a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts +++ b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts @@ -209,13 +209,8 @@ export class ContextKeysContribution extends Disposable { } } - private async _updateClientByokEnabledContext() { - try { - const copilotToken = await this._authenticationService.getCopilotToken(); - commands.executeCommand('setContext', clientByokEnabledContextKey, copilotToken.isClientBYOKEnabled()); - } catch (e) { - commands.executeCommand('setContext', clientByokEnabledContextKey, undefined); - } + private _updateClientByokEnabledContext() { + commands.executeCommand('setContext', clientByokEnabledContextKey, true); } private _updateShowLogViewContext() { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 942db76b1acea..de0d59a874aff 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -861,7 +861,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Special case 2: User has exceeded quota (needs to upgrade) const chatSentiment = this.chatEntitlementService.sentiment; const chatQuotaExceeded = this.chatEntitlementService.quotas.chat?.percentRemaining === 0; - const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown; + const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.clientByokEnabled; const anonymous = this.chatEntitlementService.anonymous; const free = this.chatEntitlementService.entitlement === ChatEntitlement.Free; diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index dd246a11eae68..88810d9040357 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -1313,7 +1313,7 @@ export class ChatModelsWidget extends Disposable { const entitlement = this.chatEntitlementService.entitlement; const isManagedEntitlement = entitlement === ChatEntitlement.Business || entitlement === ChatEntitlement.Enterprise; const supportsAddingModels = this.chatEntitlementService.isInternal - || (isManagedEntitlement && this.chatEntitlementService.clientByokEnabled) + || this.chatEntitlementService.clientByokEnabled || (entitlement !== ChatEntitlement.Unknown && entitlement !== ChatEntitlement.Available && !isManagedEntitlement); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index ffc5b0eca5ef3..a70e2598068ee 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -263,7 +263,8 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { this.context.state.entitlement === ChatEntitlement.Available || // Entitlement available: run setup to sign up ( this.context.state.entitlement === ChatEntitlement.Unknown && // Entitlement unknown: run setup to sign in / sign up - !this.chatEntitlementService.anonymous // unless anonymous access is enabled + !this.chatEntitlementService.anonymous && // unless anonymous access is enabled + !this.chatEntitlementService.clientByokEnabled // unless BYOK is enabled ) ) { return this.doInvokeWithSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService, defaultAccountService); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index a89af9806fd5e..0fb98fbd5751b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -199,7 +199,7 @@ export class ChatSetup { const styleButton = (...classes: string[]) => ({ styleButton: (button: IButton) => button.element.classList.add(...classes) }); let buttons: Array; - if (!options?.forceAnonymous && (this.context.state.entitlement === ChatEntitlement.Unknown || options?.forceSignInDialog)) { + if (!options?.forceAnonymous && !this.chatEntitlementService.clientByokEnabled && (this.context.state.entitlement === ChatEntitlement.Unknown || options?.forceSignInDialog)) { const defaultProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider.default.name), ChatSetupStrategy.SetupWithoutEnterpriseProvider, styleButton('continue-button', 'default')]; const defaultProviderLink: ContinueWithButton = [defaultProviderButton[0], defaultProviderButton[1], styleButton('link-button')]; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index e30045006ea64..fb95434a85840 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -292,7 +292,7 @@ export class ChatStatusDashboard extends DomWidget { const anonymousUser = this.chatEntitlementService.anonymous; const disabled = this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted; const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown; - if (newUser || signedOut || disabled) { + if (newUser || (signedOut && !this.chatEntitlementService.clientByokEnabled) || disabled) { this.element.appendChild($('hr')); let descriptionText: string | MarkdownString; @@ -470,7 +470,7 @@ export class ChatStatusDashboard extends DomWidget { } if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown || this.chatEntitlementService.entitlement === ChatEntitlement.Available) { - return this.chatEntitlementService.anonymous; // signed out or not-yet-signed-up users can only use Chat if anonymous access is allowed + return this.chatEntitlementService.anonymous || this.chatEntitlementService.clientByokEnabled; // signed out or not-yet-signed-up users can only use Chat if anonymous access or BYOK is allowed } if (this.chatEntitlementService.entitlement === ChatEntitlement.Free && this.chatEntitlementService.quotas.chat?.percentRemaining === 0 && this.chatEntitlementService.quotas.completions?.percentRemaining === 0) { diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index bbce59557f403..bb175b454cf0f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -147,7 +147,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu } // Signed out - else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) { + else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.clientByokEnabled) { const signInExperiment = this.configurationService.getValue(ChatConfiguration.SignInTitleBarEnabled); if (signInExperiment) { const signIn = localize('signIn', "Sign In"); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 78b8aea372ccf..2bd5c7a1653f3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -429,7 +429,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } // Tips are only relevant after sign-in has completed. - if (this._chatEntitlementService.entitlement === ChatEntitlement.Unknown) { + if (this._chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this._chatEntitlementService.clientByokEnabled) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 1c36aa549cd97..e1017a381c158 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -156,7 +156,7 @@ export class ChatTipContentPart extends Disposable { return true; } - return this._chatEntitlementService.entitlement === ChatEntitlement.Unknown; + return this._chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this._chatEntitlementService.clientByokEnabled; } } diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts index 40e38cd8e0699..8b054c14fe014 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts @@ -262,7 +262,7 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ type: 'steps', steps: [ createCopilotSetupStep('CopilotSetupAnonymous', CopilotAnonymousButton, 'chatAnonymous && !chatSetupCompleted', true), - createCopilotSetupStep('CopilotSetupSignedOut', CopilotSignedOutButton, 'chatEntitlementSignedOut && !chatAnonymous', false), + createCopilotSetupStep('CopilotSetupSignedOut', CopilotSignedOutButton, 'chatEntitlementSignedOut && !chatAnonymous && !github.copilot.clientByokEnabled', false), createCopilotSetupStep('CopilotSetupComplete', CopilotCompleteButton, 'chatSetupCompleted && !chatSetupDisabled && (chatAnonymous || chatPlanPro || chatPlanProPlus || chatPlanBusiness || chatPlanEnterprise || chatPlanFree)', false), createCopilotSetupStep('CopilotSetupSignedIn', CopilotSignedInButton, '!chatEntitlementSignedOut && (!chatSetupCompleted || chatSetupDisabled || chatPlanCanSignUp)', false), { From 068fa20b40ab80e20506665a935e28c859fff392 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 23 Apr 2026 14:09:19 -0700 Subject: [PATCH 2/8] PR feedback Co-authored-by: Copilot --- .../byok/vscode-node/byokContribution.ts | 29 +++++++++++-------- .../vscode-node/contextKeys.contribution.ts | 2 +- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts index a8a5bada6985e..7e218467e48c9 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts @@ -35,7 +35,10 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { ) { super(); this._byokStorageService = new BYOKStorageService(extensionContext); - this._registerProviders(); + void this._registerProviders().catch(err => { + this._byokProvidersRegistered = false; + this._logService.error('BYOK: Failed to register providers.', err); + }); } private async _registerProviders() { @@ -64,17 +67,19 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { } } private async fetchKnownModelList(fetcherService: IFetcherService): Promise> { - const data = await (await fetcherService.fetch('https://main.vscode-cdn.net/extensions/copilotChat.json', { method: 'GET', callSite: 'byok-known-models' })).json(); - // Use this for testing with changes from a local file. Don't check in - // const data = JSON.parse((await this._fileSystemService.readFile(URI.file('/Users/roblou/code/vscode-engineering/chat/copilotChat.json'))).toString()); - let knownModels: Record; - if (data.version !== 1) { - this._logService.warn('BYOK: Copilot Chat known models list is not in the expected format. Defaulting to empty list.'); - knownModels = {}; - } else { - knownModels = data.modelInfo; + try { + const data = await (await fetcherService.fetch('https://main.vscode-cdn.net/extensions/copilotChat.json', { method: 'GET', callSite: 'byok-known-models' })).json(); + // Use this for testing with changes from a local file. Don't check in + // const data = JSON.parse((await this._fileSystemService.readFile(URI.file('/Users/roblou/code/vscode-engineering/chat/copilotChat.json'))).toString()); + if (data.version !== 1) { + this._logService.warn('BYOK: Copilot Chat known models list is not in the expected format. Defaulting to empty list.'); + return {}; + } + this._logService.info('BYOK: Copilot Chat known models list fetched successfully.'); + return data.modelInfo; + } catch (err) { + this._logService.warn(`BYOK: Failed to fetch known models list. Defaulting to empty list. ${err}`); + return {}; } - this._logService.info('BYOK: Copilot Chat known models list fetched successfully.'); - return knownModels; } } diff --git a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts index 54b655baf3ee8..2b6504c410567 100644 --- a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts +++ b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts @@ -209,7 +209,7 @@ export class ContextKeysContribution extends Disposable { } } - private _updateClientByokEnabledContext() { + private async _updateClientByokEnabledContext() { commands.executeCommand('setContext', clientByokEnabledContextKey, true); } From d9c93807c82d2390fc8513b42cbf6bd7fe7292ff Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 23 Apr 2026 17:54:22 -0700 Subject: [PATCH 3/8] Fixes Co-authored-by: Copilot --- .../byok/vscode-node/byokContribution.ts | 19 ++++++++++++++++++- src/vs/sessions/test/web.test.ts | 1 + .../experiments/agentTitleBarStatusWidget.ts | 2 +- .../chatManagement.contribution.ts | 3 ++- .../browser/chatSetup/chatSetupProviders.ts | 5 +++-- .../chat/browser/chatSetup/chatSetupRunner.ts | 2 +- .../browser/chatStatus/chatStatusDashboard.ts | 10 +++++----- .../browser/chatStatus/chatStatusEntry.ts | 2 +- .../contrib/chat/browser/chatTipService.ts | 2 +- .../chatContentParts/chatTipContentPart.ts | 2 +- .../contrib/chat/browser/widget/chatWidget.ts | 2 +- .../browser/widget/input/chatModelPicker.ts | 3 ++- .../widgetHosts/viewPane/chatViewPane.ts | 2 +- .../common/gettingStartedContent.ts | 2 +- .../chat/common/chatEntitlementService.ts | 5 +++++ .../test/common/workbenchTestServices.ts | 1 + 16 files changed, 45 insertions(+), 18 deletions(-) diff --git a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts index 7e218467e48c9..8b6ab98d227a2 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { LanguageModelChatInformation, LanguageModelChatProvider, lm } from 'vscode'; +import { commands, LanguageModelChatInformation, LanguageModelChatProvider, lm } from 'vscode'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { ILogService } from '../../../platform/log/common/logService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; @@ -20,6 +20,8 @@ import { OAIBYOKLMProvider } from './openAIProvider'; import { OpenRouterLMProvider } from './openRouterProvider'; import { XAIBYOKLMProvider } from './xAIProvider'; +export const hasByokModelsContextKey = 'github.copilot.hasByokModels'; + export class BYOKContrib extends Disposable implements IExtensionContribution { public readonly id: string = 'byok-contribution'; private readonly _byokStorageService: IBYOKStorageService; @@ -65,7 +67,22 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { for (const [providerName, provider] of this._providers) { this._byokRegistrations.add(lm.registerLanguageModelChatProvider(providerName, provider)); } + + await this._updateHasByokModelsContext(); + + // Update context key when language models change (e.g., model configured/removed) + this._register(lm.onDidChangeChatModels(() => { + this._updateHasByokModelsContext(); + })); } + + async _updateHasByokModelsContext(): Promise { + const byokVendors = new Set(this._providers.keys()); + const models = await lm.selectChatModels(); + const hasModels = models.some(model => byokVendors.has(model.vendor)); + commands.executeCommand('setContext', hasByokModelsContextKey, hasModels); + } + private async fetchKnownModelList(fetcherService: IFetcherService): Promise> { try { const data = await (await fetcherService.fetch('https://main.vscode-cdn.net/extensions/copilotChat.json', { method: 'GET', callSite: 'byok-known-models' })).json(); diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index c2b0c49d7a536..d1e884c2072c5 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -94,6 +94,7 @@ class MockChatEntitlementService implements IChatEntitlementService { readonly previewFeaturesDisabled = false; readonly clientByokEnabled = false; + readonly hasByokModels = false; readonly organisations: string[] | undefined = undefined; readonly isInternal = false; readonly sku = 'free'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index de0d59a874aff..3fa6180d1d83a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -861,7 +861,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Special case 2: User has exceeded quota (needs to upgrade) const chatSentiment = this.chatEntitlementService.sentiment; const chatQuotaExceeded = this.chatEntitlementService.quotas.chat?.percentRemaining === 0; - const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.clientByokEnabled; + const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.hasByokModels; const anonymous = this.chatEntitlementService.anonymous; const free = this.chatEntitlementService.entitlement === ChatEntitlement.Free; diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts index 418c57e8aab7e..c8face1787678 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts @@ -36,7 +36,8 @@ const LANGUAGE_MODELS_ENTITLEMENT_PRECONDITION = ContextKeyExpr.and(ChatContextK ChatContextKeys.Entitlement.planProPlus, ChatContextKeys.Entitlement.planBusiness, ChatContextKeys.Entitlement.planEnterprise, - ChatContextKeys.Entitlement.internal + ChatContextKeys.Entitlement.internal, + ContextKeyExpr.equals('github.copilot.clientByokEnabled', true) )); Registry.as(EditorExtensions.EditorPane).registerEditorPane( diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index a70e2598068ee..d033549058561 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -256,15 +256,16 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } private async doInvoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService, defaultAccountService: IDefaultAccountService): Promise { + const hasByokModels = this.chatEntitlementService.hasByokModels; if ( - !this.context.state.completed || // Setup not completed + (!this.context.state.completed && !hasByokModels) || // Setup not completed (unless BYOK models are configured) this.context.state.disabled || // Extension disabled: run setup to enable this.context.state.untrusted || // Workspace untrusted: run setup to ask for trust this.context.state.entitlement === ChatEntitlement.Available || // Entitlement available: run setup to sign up ( this.context.state.entitlement === ChatEntitlement.Unknown && // Entitlement unknown: run setup to sign in / sign up !this.chatEntitlementService.anonymous && // unless anonymous access is enabled - !this.chatEntitlementService.clientByokEnabled // unless BYOK is enabled + !hasByokModels // unless BYOK models are configured ) ) { return this.doInvokeWithSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService, defaultAccountService); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 0fb98fbd5751b..509d8b922279b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -199,7 +199,7 @@ export class ChatSetup { const styleButton = (...classes: string[]) => ({ styleButton: (button: IButton) => button.element.classList.add(...classes) }); let buttons: Array; - if (!options?.forceAnonymous && !this.chatEntitlementService.clientByokEnabled && (this.context.state.entitlement === ChatEntitlement.Unknown || options?.forceSignInDialog)) { + if (!options?.forceAnonymous && !this.chatEntitlementService.hasByokModels && (this.context.state.entitlement === ChatEntitlement.Unknown || options?.forceSignInDialog)) { const defaultProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider.default.name), ChatSetupStrategy.SetupWithoutEnterpriseProvider, styleButton('continue-button', 'default')]; const defaultProviderLink: ContinueWithButton = [defaultProviderButton[0], defaultProviderButton[1], styleButton('link-button')]; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index fb95434a85840..a4831a654f880 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -288,11 +288,11 @@ export class ChatStatusDashboard extends DomWidget { // New to Chat / Signed out { - const newUser = isNewUser(this.chatEntitlementService); + const newUser = isNewUser(this.chatEntitlementService) && !this.chatEntitlementService.hasByokModels; const anonymousUser = this.chatEntitlementService.anonymous; const disabled = this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted; const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown; - if (newUser || (signedOut && !this.chatEntitlementService.clientByokEnabled) || disabled) { + if (newUser || (signedOut && !this.chatEntitlementService.hasByokModels) || disabled) { this.element.appendChild($('hr')); let descriptionText: string | MarkdownString; @@ -465,12 +465,12 @@ export class ChatStatusDashboard extends DomWidget { } private canUseChat(): boolean { - if (!this.chatEntitlementService.sentiment.completed || this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted) { - return false; // chat not completed or not enabled + if ((!this.chatEntitlementService.sentiment.completed && !this.chatEntitlementService.hasByokModels) || this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted) { + return false; // chat not completed or not enabled (unless BYOK models are configured) } if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown || this.chatEntitlementService.entitlement === ChatEntitlement.Available) { - return this.chatEntitlementService.anonymous || this.chatEntitlementService.clientByokEnabled; // signed out or not-yet-signed-up users can only use Chat if anonymous access or BYOK is allowed + return this.chatEntitlementService.anonymous || this.chatEntitlementService.hasByokModels; // signed out or not-yet-signed-up users can only use Chat if anonymous access or BYOK models are configured } if (this.chatEntitlementService.entitlement === ChatEntitlement.Free && this.chatEntitlementService.quotas.chat?.percentRemaining === 0 && this.chatEntitlementService.quotas.completions?.percentRemaining === 0) { diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index bb175b454cf0f..8c4d4e223d5b8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -147,7 +147,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu } // Signed out - else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.clientByokEnabled) { + else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.hasByokModels) { const signInExperiment = this.configurationService.getValue(ChatConfiguration.SignInTitleBarEnabled); if (signInExperiment) { const signIn = localize('signIn', "Sign In"); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 2bd5c7a1653f3..3c146a90cbe81 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -429,7 +429,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } // Tips are only relevant after sign-in has completed. - if (this._chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this._chatEntitlementService.clientByokEnabled) { + if (this._chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this._chatEntitlementService.hasByokModels) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index e1017a381c158..2413ccfe02af5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -156,7 +156,7 @@ export class ChatTipContentPart extends Disposable { return true; } - return this._chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this._chatEntitlementService.clientByokEnabled; + return this._chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this._chatEntitlementService.hasByokModels; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 494623317332d..dd7e2e2d38905 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1006,7 +1006,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } // Only show welcome getting started until setup is completed - this.container.classList.toggle('chat-view-getting-started-disabled', this.chatEntitlementService.sentiment.completed); + this.container.classList.toggle('chat-view-getting-started-disabled', this.chatEntitlementService.sentiment.completed || this.chatEntitlementService.hasByokModels); this._onDidChangeEmptyState.fire(); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 35c64cfda5c22..1aaa8d143d03c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -169,7 +169,8 @@ function createModelAction( } function shouldShowManageModelsAction(chatEntitlementService: IChatEntitlementService): boolean { - return chatEntitlementService.entitlement === ChatEntitlement.Free || + return chatEntitlementService.clientByokEnabled || + chatEntitlementService.entitlement === ChatEntitlement.Free || chatEntitlementService.entitlement === ChatEntitlement.EDU || chatEntitlementService.entitlement === ChatEntitlement.Pro || chatEntitlementService.entitlement === ChatEntitlement.ProPlus || diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 1ccad289c114f..a5d7ca8dbbfff 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -479,7 +479,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Sessions control: stacked if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { newSessionsContainerVisible = - !!this.chatEntitlementService.sentiment.completed && // chat is setup (otherwise make room for terms and welcome) + (!!this.chatEntitlementService.sentiment.completed || this.chatEntitlementService.hasByokModels) && // chat is setup (otherwise make room for terms and welcome) (!this._widget || (this._widget.isEmpty() && !!this._widget.viewModel && !this._widget.viewModel.model.title)) && // chat widget empty (but not when model is loading or has a title) !this.welcomeController?.isShowingWelcome.get(); // welcome not showing } diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts index 8b054c14fe014..ffc7379a65dd2 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts @@ -262,7 +262,7 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ type: 'steps', steps: [ createCopilotSetupStep('CopilotSetupAnonymous', CopilotAnonymousButton, 'chatAnonymous && !chatSetupCompleted', true), - createCopilotSetupStep('CopilotSetupSignedOut', CopilotSignedOutButton, 'chatEntitlementSignedOut && !chatAnonymous && !github.copilot.clientByokEnabled', false), + createCopilotSetupStep('CopilotSetupSignedOut', CopilotSignedOutButton, 'chatEntitlementSignedOut && !chatAnonymous && !github.copilot.hasByokModels', false), createCopilotSetupStep('CopilotSetupComplete', CopilotCompleteButton, 'chatSetupCompleted && !chatSetupDisabled && (chatAnonymous || chatPlanPro || chatPlanProPlus || chatPlanBusiness || chatPlanEnterprise || chatPlanFree)', false), createCopilotSetupStep('CopilotSetupSignedIn', CopilotSignedInButton, '!chatEntitlementSignedOut && (!chatSetupCompleted || chatSetupDisabled || chatPlanCanSignUp)', false), { diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index aeb47c86f7106..1a68bf2670cff 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -161,6 +161,7 @@ export interface IChatEntitlementService { readonly previewFeaturesDisabled: boolean; readonly clientByokEnabled: boolean; + readonly hasByokModels: boolean; readonly organisations: string[] | undefined; readonly isInternal: boolean; @@ -419,6 +420,10 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme return this.contextKeyService.getContextKeyValue('github.copilot.clientByokEnabled') === true; } + get hasByokModels(): boolean { + return this.contextKeyService.getContextKeyValue('github.copilot.hasByokModels') === true; + } + //#endregion //#region --- Quotas diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index bb27e6fecf459..397c0a52af247 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -816,6 +816,7 @@ export class TestChatEntitlementService implements IChatEntitlementService { readonly previewFeaturesDisabled = false; readonly clientByokEnabled = false; + readonly hasByokModels = false; } export class TestLifecycleService extends Disposable implements ILifecycleService { From 44bd6d6fe03d6ca15e3232367cd5b6a731f08c96 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 23 Apr 2026 18:07:48 -0700 Subject: [PATCH 4/8] Fix --- .../vscode-node/contextKeys.contribution.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts index 2b6504c410567..da3293a063f7a 100644 --- a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts +++ b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts @@ -210,7 +210,17 @@ export class ContextKeysContribution extends Disposable { } private async _updateClientByokEnabledContext() { - commands.executeCommand('setContext', clientByokEnabledContextKey, true); + try { + const copilotToken = await this._authenticationService.getCopilotToken(); + // When signed in, respect enterprise BYOK policy: + // internal and individual users always have BYOK enabled, + // managed (enterprise/business) users require explicit org enablement. + const byokEnabled = copilotToken.isInternal || copilotToken.isIndividual || copilotToken.isClientBYOKEnabled(); + commands.executeCommand('setContext', clientByokEnabledContextKey, byokEnabled); + } catch (e) { + // When not signed in, BYOK is available by default + commands.executeCommand('setContext', clientByokEnabledContextKey, true); + } } private _updateShowLogViewContext() { From 3c61bca4a6e7e033982b974e21fd415e48e76dd7 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 23 Apr 2026 18:10:00 -0700 Subject: [PATCH 5/8] PR feedback Co-authored-by: Copilot --- .../byok/vscode-node/byokContribution.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts index 8b6ab98d227a2..21da2395eb118 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts @@ -72,15 +72,27 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { // Update context key when language models change (e.g., model configured/removed) this._register(lm.onDidChangeChatModels(() => { - this._updateHasByokModelsContext(); + void this._updateHasByokModelsContext().catch(err => { + this._logService.error('BYOK: Failed to update BYOK models context.', err); + }); })); } async _updateHasByokModelsContext(): Promise { - const byokVendors = new Set(this._providers.keys()); - const models = await lm.selectChatModels(); - const hasModels = models.some(model => byokVendors.has(model.vendor)); - commands.executeCommand('setContext', hasByokModelsContextKey, hasModels); + try { + let hasModels = false; + for (const vendor of this._providers.keys()) { + const models = await lm.selectChatModels({ vendor }); + if (models.length > 0) { + hasModels = true; + break; + } + } + commands.executeCommand('setContext', hasByokModelsContextKey, hasModels); + } catch (err) { + this._logService.error('BYOK: Failed to update BYOK models context.', err); + commands.executeCommand('setContext', hasByokModelsContextKey, false); + } } private async fetchKnownModelList(fetcherService: IFetcherService): Promise> { From dc354a33c86f7bb33d5a297314f2ea69d23aa574 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 24 Apr 2026 14:29:47 -0700 Subject: [PATCH 6/8] PR feedback --- .../byok/vscode-node/byokContribution.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts index 21da2395eb118..4d89f82cc9817 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts @@ -39,7 +39,7 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { this._byokStorageService = new BYOKStorageService(extensionContext); void this._registerProviders().catch(err => { this._byokProvidersRegistered = false; - this._logService.error('BYOK: Failed to register providers.', err); + this._logService.error(err instanceof Error ? err : String(err), 'BYOK: Failed to register providers.'); }); } @@ -50,11 +50,14 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { this._byokProvidersRegistered = true; const instantiationService = this._instantiationService; - // Update known models list from CDN so all providers have the same list - const knownModels = await this.fetchKnownModelList(this._fetcherService); + + // Fetch known models from CDN for model metadata (capabilities, token limits). + // Uses a timeout to avoid blocking provider registration in air-gapped/offline environments. + const knownModels = await this._fetchKnownModelListWithTimeout(this._fetcherService); if (this._store.isDisposed) { return; } + this._providers.set(OllamaLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OllamaLMProvider, this._byokStorageService)); this._providers.set(AnthropicLMProvider.providerName.toLowerCase(), instantiationService.createInstance(AnthropicLMProvider, knownModels[AnthropicLMProvider.providerName], this._byokStorageService)); this._providers.set(GeminiNativeBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(GeminiNativeBYOKLMProvider, knownModels[GeminiNativeBYOKLMProvider.providerName], this._byokStorageService)); @@ -73,7 +76,7 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { // Update context key when language models change (e.g., model configured/removed) this._register(lm.onDidChangeChatModels(() => { void this._updateHasByokModelsContext().catch(err => { - this._logService.error('BYOK: Failed to update BYOK models context.', err); + this._logService.error(err instanceof Error ? err : String(err), 'BYOK: Failed to update BYOK models context.'); }); })); } @@ -90,11 +93,22 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { } commands.executeCommand('setContext', hasByokModelsContextKey, hasModels); } catch (err) { - this._logService.error('BYOK: Failed to update BYOK models context.', err); + this._logService.error(err instanceof Error ? err : String(err), 'BYOK: Failed to update BYOK models context.'); commands.executeCommand('setContext', hasByokModelsContextKey, false); } } + private async _fetchKnownModelListWithTimeout(fetcherService: IFetcherService): Promise> { + const CDN_FETCH_TIMEOUT_MS = 5000; + return Promise.race([ + this.fetchKnownModelList(fetcherService), + new Promise>(resolve => setTimeout(() => { + this._logService.warn('BYOK: CDN fetch timed out. Registering providers with empty known models list.'); + resolve({}); + }, CDN_FETCH_TIMEOUT_MS)) + ]); + } + private async fetchKnownModelList(fetcherService: IFetcherService): Promise> { try { const data = await (await fetcherService.fetch('https://main.vscode-cdn.net/extensions/copilotChat.json', { method: 'GET', callSite: 'byok-known-models' })).json(); From a8547cfffcfa7cf7f18cfeda29db67b23377ecae Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 24 Apr 2026 21:40:34 -0700 Subject: [PATCH 7/8] Bug fixes Co-authored-by: Copilot --- .../browser/chatManagement/chatManagement.contribution.ts | 3 ++- .../services/chat/common/chatEntitlementService.ts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts index c8face1787678..560021363826b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts @@ -17,6 +17,7 @@ import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; import { CONTEXT_MODELS_EDITOR, CONTEXT_MODELS_SEARCH_FOCUS, MANAGE_CHAT_COMMAND_ID } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { ModelsManagementEditor } from './chatManagementEditor.js'; @@ -37,7 +38,7 @@ const LANGUAGE_MODELS_ENTITLEMENT_PRECONDITION = ContextKeyExpr.and(ChatContextK ChatContextKeys.Entitlement.planBusiness, ChatContextKeys.Entitlement.planEnterprise, ChatContextKeys.Entitlement.internal, - ContextKeyExpr.equals('github.copilot.clientByokEnabled', true) + ChatEntitlementContextKeys.clientByokEnabled )); Registry.as(EditorExtensions.EditorPane).registerEditorPane( diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 1a68bf2670cff..5498b3620ef8c 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -66,6 +66,10 @@ export namespace ChatEntitlementContextKeys { export const completionsQuotaExceeded = new RawContextKey('completionsQuotaExceeded', false, true); export const chatAnonymous = new RawContextKey('chatAnonymous', false, true); + + // BYOK context keys: defaults to true so BYOK UI is available before the Copilot extension + // refines based on the user's token (e.g., enterprise policy may set to false). + export const clientByokEnabled = new RawContextKey('github.copilot.clientByokEnabled', true, true); } export const IChatEntitlementService = createDecorator('chatEntitlementService'); @@ -314,6 +318,8 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme this.chatQuotaExceededContextKey = ChatEntitlementContextKeys.chatQuotaExceeded.bindTo(this.contextKeyService); this.completionsQuotaExceededContextKey = ChatEntitlementContextKeys.completionsQuotaExceeded.bindTo(this.contextKeyService); + ChatEntitlementContextKeys.clientByokEnabled.bindTo(this.contextKeyService); + this.anonymousContextKey = ChatEntitlementContextKeys.chatAnonymous.bindTo(this.contextKeyService); this.anonymousContextKey.set(this.anonymous); From 72583160e6fb16bc4ca338b2996dab5b2d028c88 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 25 Apr 2026 15:51:34 -0700 Subject: [PATCH 8/8] Bug fixes Co-authored-by: Copilot --- .../byok/vscode-node/anthropicProvider.ts | 3 ++- .../byok/vscode-node/azureProvider.ts | 3 ++- .../byok/vscode-node/byokContribution.ts | 27 +++++++++++++------ .../byok/vscode-node/customOAIProvider.ts | 5 ++-- .../byok/vscode-node/geminiNativeProvider.ts | 3 ++- .../byok/vscode-node/ollamaProvider.ts | 5 ++-- .../byok/vscode-node/openAIProvider.ts | 3 ++- .../byok/vscode-node/openRouterProvider.ts | 5 ++-- .../extension/byok/vscode-node/xAIProvider.ts | 3 ++- .../vscode-node/chatParticipants.ts | 8 ++++-- .../vscode-node/conversationFeature.ts | 25 +++++++++++++++++ .../vscode-node/languageModelAccess.ts | 8 +++++- .../extension/vscode-node/contributions.ts | 2 +- .../extension/prompt/node/chatMLFetcher.ts | 20 +++++++++----- .../node/defaultIntentRequestHandler.ts | 27 ++++++++++++------- .../extension/prompt/node/intentDetector.tsx | 9 ++++++- .../src/platform/chat/common/commonTypes.ts | 4 +-- .../endpoint/node/modelMetadataFetcher.ts | 10 ++++++- .../chatSetup/chatSetupContributions.ts | 8 +++++- .../browser/chatSetup/chatSetupProviders.ts | 14 +++++++++- .../browser/chatStatus/chatStatusEntry.ts | 9 +++++++ 21 files changed, 157 insertions(+), 44 deletions(-) diff --git a/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts index a76a75b025d4e..9a221c62e9091 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts @@ -34,6 +34,7 @@ import { IBYOKStorageService } from './byokStorageService'; export class AnthropicLMProvider extends AbstractLanguageModelChatProvider { public static readonly providerName = 'Anthropic'; + public static readonly providerId = 'anthropic'; constructor( knownModels: BYOKKnownModels | undefined, @@ -46,7 +47,7 @@ export class AnthropicLMProvider extends AbstractLanguageModelChatProvider { @IOTelService private readonly _otelService: IOTelService, @IToolDeferralService private readonly _toolDeferralService: IToolDeferralService, ) { - super(AnthropicLMProvider.providerName.toLowerCase(), AnthropicLMProvider.providerName, knownModels, byokStorageService, logService); + super(AnthropicLMProvider.providerId, AnthropicLMProvider.providerName, knownModels, byokStorageService, logService); } diff --git a/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts index 56f5bb2d617d6..a72fcd7cfa1d8 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts @@ -48,6 +48,7 @@ export function resolveAzureUrl(modelId: string, url: string): string { export class AzureBYOKModelProvider extends AbstractCustomOAIBYOKModelProvider { static readonly providerName = 'Azure'; + static readonly providerId = 'azure'; constructor( byokStorageService: IBYOKStorageService, @@ -59,7 +60,7 @@ export class AzureBYOKModelProvider extends AbstractCustomOAIBYOKModelProvider { @IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext ) { super( - AzureBYOKModelProvider.providerName.toLowerCase(), + AzureBYOKModelProvider.providerId, AzureBYOKModelProvider.providerName, byokStorageService, logService, diff --git a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts index 4d89f82cc9817..043da0c24f266 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts @@ -22,6 +22,17 @@ import { XAIBYOKLMProvider } from './xAIProvider'; export const hasByokModelsContextKey = 'github.copilot.hasByokModels'; +export const byokVendorIds = [ + OllamaLMProvider.providerId, + AnthropicLMProvider.providerId, + GeminiNativeBYOKLMProvider.providerId, + XAIBYOKLMProvider.providerId, + OAIBYOKLMProvider.providerId, + OpenRouterLMProvider.providerId, + AzureBYOKModelProvider.providerId, + CustomOAIBYOKModelProvider.providerId, +]; + export class BYOKContrib extends Disposable implements IExtensionContribution { public readonly id: string = 'byok-contribution'; private readonly _byokStorageService: IBYOKStorageService; @@ -58,14 +69,14 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { return; } - this._providers.set(OllamaLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OllamaLMProvider, this._byokStorageService)); - this._providers.set(AnthropicLMProvider.providerName.toLowerCase(), instantiationService.createInstance(AnthropicLMProvider, knownModels[AnthropicLMProvider.providerName], this._byokStorageService)); - this._providers.set(GeminiNativeBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(GeminiNativeBYOKLMProvider, knownModels[GeminiNativeBYOKLMProvider.providerName], this._byokStorageService)); - this._providers.set(XAIBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(XAIBYOKLMProvider, knownModels[XAIBYOKLMProvider.providerName], this._byokStorageService)); - this._providers.set(OAIBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OAIBYOKLMProvider, knownModels[OAIBYOKLMProvider.providerName], this._byokStorageService)); - this._providers.set(OpenRouterLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OpenRouterLMProvider, this._byokStorageService)); - this._providers.set(AzureBYOKModelProvider.providerName.toLowerCase(), instantiationService.createInstance(AzureBYOKModelProvider, this._byokStorageService)); - this._providers.set(CustomOAIBYOKModelProvider.providerName.toLowerCase(), instantiationService.createInstance(CustomOAIBYOKModelProvider, this._byokStorageService)); + this._providers.set(OllamaLMProvider.providerId, instantiationService.createInstance(OllamaLMProvider, this._byokStorageService)); + this._providers.set(AnthropicLMProvider.providerId, instantiationService.createInstance(AnthropicLMProvider, knownModels[AnthropicLMProvider.providerName], this._byokStorageService)); + this._providers.set(GeminiNativeBYOKLMProvider.providerId, instantiationService.createInstance(GeminiNativeBYOKLMProvider, knownModels[GeminiNativeBYOKLMProvider.providerName], this._byokStorageService)); + this._providers.set(XAIBYOKLMProvider.providerId, instantiationService.createInstance(XAIBYOKLMProvider, knownModels[XAIBYOKLMProvider.providerName], this._byokStorageService)); + this._providers.set(OAIBYOKLMProvider.providerId, instantiationService.createInstance(OAIBYOKLMProvider, knownModels[OAIBYOKLMProvider.providerName], this._byokStorageService)); + this._providers.set(OpenRouterLMProvider.providerId, instantiationService.createInstance(OpenRouterLMProvider, this._byokStorageService)); + this._providers.set(AzureBYOKModelProvider.providerId, instantiationService.createInstance(AzureBYOKModelProvider, this._byokStorageService)); + this._providers.set(CustomOAIBYOKModelProvider.providerId, instantiationService.createInstance(CustomOAIBYOKModelProvider, this._byokStorageService)); for (const [providerName, provider] of this._providers) { this._byokRegistrations.add(lm.registerLanguageModelChatProvider(providerName, provider)); diff --git a/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts index 772e2cf446069..4ef278f1352ad 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts @@ -164,6 +164,7 @@ export abstract class AbstractCustomOAIBYOKModelProvider extends AbstractOpenAIC export class CustomOAIBYOKModelProvider extends AbstractCustomOAIBYOKModelProvider { static readonly providerName: string = 'CustomOAI'; + static readonly providerId = 'customoai'; private providerName: string = CustomOAIBYOKModelProvider.providerName; constructor( @@ -175,7 +176,7 @@ export class CustomOAIBYOKModelProvider extends AbstractCustomOAIBYOKModelProvid @IExperimentationService expService: IExperimentationService, @IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext ) { - super(CustomOAIBYOKModelProvider.providerName.toLowerCase(), CustomOAIBYOKModelProvider.providerName, _byokStorageService, logService, fetcherService, instantiationService, configurationService, expService, extensionContext); + super(CustomOAIBYOKModelProvider.providerId, CustomOAIBYOKModelProvider.providerName, _byokStorageService, logService, fetcherService, instantiationService, configurationService, expService, extensionContext); this.migrateExistingConfigs(); } @@ -187,4 +188,4 @@ export class CustomOAIBYOKModelProvider extends AbstractCustomOAIBYOKModelProvid protected resolveUrl(modelId: string, url: string): string { return resolveCustomOAIUrl(modelId, url); } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts index 5a3ff62a458bb..9e478506f7b7d 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts @@ -26,6 +26,7 @@ import { IBYOKStorageService } from './byokStorageService'; export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvider { public static readonly providerName = 'Gemini'; + public static readonly providerId = 'gemini'; constructor( knownModels: BYOKKnownModels | undefined, @@ -35,7 +36,7 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide @ITelemetryService private readonly _telemetryService: ITelemetryService, @IOTelService private readonly _otelService: IOTelService, ) { - super(GeminiNativeBYOKLMProvider.providerName.toLowerCase(), GeminiNativeBYOKLMProvider.providerName, knownModels, byokStorageService, logService); + super(GeminiNativeBYOKLMProvider.providerId, GeminiNativeBYOKLMProvider.providerName, knownModels, byokStorageService, logService); } protected async getAllModels(silent: boolean, apiKey: string | undefined): Promise[]> { diff --git a/extensions/copilot/src/extension/byok/vscode-node/ollamaProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/ollamaProvider.ts index 43598527d80cb..7eaf9821f79d1 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/ollamaProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/ollamaProvider.ts @@ -39,6 +39,7 @@ export interface OllamaConfig extends LanguageModelChatConfiguration { export class OllamaLMProvider extends AbstractOpenAICompatibleLMProvider { public static readonly providerName = 'Ollama'; + public static readonly providerId = 'ollama'; private _modelCache = new Map(); constructor( @@ -50,7 +51,7 @@ export class OllamaLMProvider extends AbstractOpenAICompatibleLMProvider { + // For BYOK models there is no notion of a "base" model and no premium request allowance to switch from. + if (request.model.vendor !== 'copilot') { + return request; + } const endpoint = await this.endpointProvider.getChatEndpoint(request); - const baseEndpoint = await this.endpointProvider.getChatEndpoint('copilot-base'); // If it has a 0x multipler, it's free so don't switch them. If it's BYOK, it's free so don't switch them. - if (endpoint.multiplier === 0 || request.model.vendor !== 'copilot' || endpoint.multiplier === undefined) { + if (endpoint.multiplier === 0 || endpoint.multiplier === undefined) { return request; } if (this._chatQuotaService.overagesEnabled || !this._chatQuotaService.quotaExhausted) { return request; } + const baseEndpoint = await this.endpointProvider.getChatEndpoint('copilot-base'); const baseLmModel = (await vscode.lm.selectChatModels({ id: baseEndpoint.model, family: baseEndpoint.family, vendor: 'copilot' }))[0]; if (!baseLmModel) { return request; diff --git a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts index 41f331a5522de..2d8b4d9adfcd7 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts @@ -25,6 +25,7 @@ import { DisposableStore, IDisposable, combinedDisposable } from '../../../util/ import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ContributionCollection, IExtensionContribution } from '../../common/contributions'; +import { byokVendorIds } from '../../byok/vscode-node/byokContribution'; import { vscodeNodeChatContributions } from '../../extension/vscode-node/contributions'; import { IMergeConflictService } from '../../git/common/mergeConflictService'; import { registerInlineChatCommands } from '../../inlineChat/vscode-node/inlineChatCommands'; @@ -111,6 +112,30 @@ export class ConversationFeature implements IExtensionContribution { activationBlockerDeferred.complete(); })); + + // Also activate when BYOK models become available (even without sign-in) + this._disposables.add(vscode.lm.onDidChangeChatModels(() => { + if (!this._activated) { + void this._checkByokModelsAndActivate(activationBlockerDeferred); + } + })); + + // Check for already-registered BYOK models (may have been registered before this listener) + if (!this._activated) { + void this._checkByokModelsAndActivate(activationBlockerDeferred); + } + } + + private async _checkByokModelsAndActivate(activationBlockerDeferred: DeferredPromise): Promise { + for (const vendor of byokVendorIds) { + const models = await vscode.lm.selectChatModels({ vendor }); + if (models.length > 0) { + this.logService.info('ConversationFeature: Activating due to BYOK models becoming available'); + this.activated = true; + activationBlockerDeferred.complete(); + return; + } + } } get enabled() { diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index d110d56dae37d..3f3c0d066c34b 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -587,7 +587,13 @@ export class CopilotLanguageModelWrapper extends Disposable { throw vscode.LanguageModelError.Blocked(blockedExtensionMessage); } else if (result.type === ChatFetchResponseType.QuotaExceeded) { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const details = getErrorDetailsFromChatFetchError(result, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + let copilotPlan: string | undefined; + try { + copilotPlan = (await this._authenticationService.getCopilotToken()).copilotPlan; + } catch { + // Not signed in (e.g., BYOK-only user); plan info unavailable + } + const details = getErrorDetailsFromChatFetchError(result, copilotPlan, outageStatus); const err = new vscode.LanguageModelError(details.message); err.name = 'ChatQuotaExceeded'; throw err; diff --git a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts index 8f7b6704a024a..0c10fc70a7950 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts @@ -102,6 +102,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(OTelContrib), asContributionFactory(SessionStoreTracker), asContributionFactory(RemoteSessionExporter), + asContributionFactory(BYOKContrib), ]; /** @@ -122,7 +123,6 @@ export const vscodeNodeChatContributions: IExtensionContributionFactory[] = [ asContributionFactory(SetupTestsContribution), asContributionFactory(FixTestFailureContribution), asContributionFactory(IgnoredFileProviderContribution), - asContributionFactory(BYOKContrib), asContributionFactory(McpSetupCommands), asContributionFactory(LanguageModelProxyContrib), asContributionFactory(PromptFileContribution), diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 934d544ed3c10..b9ac9ea28c70c 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -215,8 +215,13 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { } else { let tokenCountPromise: Promise | undefined; const countTokens = () => tokenCountPromise ??= chatEndpoint.acquireTokenizer().countMessagesTokens(messages); - const copilotToken = await this._authenticationService.getCopilotToken(); - usernameToScrub = copilotToken.username; + let copilotToken: CopilotToken | undefined; + try { + copilotToken = await this._authenticationService.getCopilotToken(); + usernameToScrub = copilotToken.username; + } catch { + // Not signed in (e.g., BYOK-only user). Continue without copilot token. + } const fetchResult = await this._fetchAndStreamChat( chatEndpoint, requestBody, @@ -835,7 +840,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { baseTelemetryData: TelemetryData, finishedCb: FinishedCallback, secretKey: string | undefined, - copilotToken: CopilotToken, + copilotToken: CopilotToken | undefined, location: ChatLocation, ourRequestId: string, nChoices: number | undefined, @@ -912,7 +917,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { baseTelemetryData: TelemetryData, finishedCb: FinishedCallback, secretKey: string | undefined, - copilotToken: CopilotToken, + copilotToken: CopilotToken | undefined, location: ChatLocation, ourRequestId: string, nChoices: number | undefined, @@ -966,8 +971,10 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._logService.debug(`modelMaxResponseTokens ${request.max_tokens ?? 2048}`); this._logService.debug(`chat model ${chatEndpointInfo.model}`); - secretKey ??= copilotToken.token; - if (!secretKey) { + secretKey ??= copilotToken?.token; + // BYOK endpoints may not need a secret key (e.g., Ollama local) — they provide their own + // auth via getExtraHeaders. Only error out if the endpoint doesn't supply its own headers. + if (!secretKey && !chatEndpointInfo.getExtraHeaders) { // If no key is set we error const urlOrRequestMetadata = stringifyUrlOrRequestMetadata(chatEndpointInfo.urlOrRequestMetadata); this._logService.error(`Failed to send request to ${urlOrRequestMetadata} due to missing key`); @@ -981,6 +988,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { } }; } + secretKey ??= ''; // WebSocket path: use persistent WebSocket connection for Responses API endpoints if (useWebSocket && turnId && conversationId) { diff --git a/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts b/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts index 8bb998886c156..22f899d32dea5 100644 --- a/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts +++ b/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts @@ -484,6 +484,15 @@ export class DefaultIntentRequestHandler { return {}; } + private async _getCopilotPlan(): Promise { + try { + return (await this._authenticationService.getCopilotToken()).copilotPlan; + } catch { + // Not signed in (e.g., BYOK-only user). Plan information is unavailable. + return undefined; + } + } + private async processResult(fetchResult: ChatResponse, responseMessage: string, chatResult: ChatResult | void, metadataFragment: Partial, baseModelTelemetry: ConversationalBaseTelemetryData, rounds: IToolCallRound[]): Promise { switch (fetchResult.type) { case ChatFetchResponseType.Success: @@ -492,7 +501,7 @@ export class DefaultIntentRequestHandler { return this.processOffTopicFetchResult(baseModelTelemetry); case ChatFetchResponseType.Canceled: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); const chatResult = { errorDetails, metadata: metadataFragment }; this.turn.setResponse(TurnStatus.Cancelled, { message: errorDetails.message, type: 'user' }, baseModelTelemetry.properties.messageId, chatResult); return chatResult; @@ -500,7 +509,7 @@ export class DefaultIntentRequestHandler { case ChatFetchResponseType.QuotaExceeded: case ChatFetchResponseType.RateLimited: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); if (fetchResult.type === ChatFetchResponseType.RateLimited && fetchResult.capiError?.code?.startsWith('user_model_rate_limited') && !fetchResult.isAuto) { @@ -521,21 +530,21 @@ export class DefaultIntentRequestHandler { case ChatFetchResponseType.NetworkError: case ChatFetchResponseType.Failed: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); const chatResult = { errorDetails, metadata: metadataFragment }; this.turn.setResponse(TurnStatus.Error, { message: errorDetails.message, type: 'server' }, baseModelTelemetry.properties.messageId, chatResult); return chatResult; } case ChatFetchResponseType.Filtered: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); const chatResult = { errorDetails, metadata: { ...metadataFragment, filterReason: fetchResult.category } }; this.turn.setResponse(TurnStatus.Filtered, undefined, baseModelTelemetry.properties.messageId, chatResult); return chatResult; } case ChatFetchResponseType.PromptFiltered: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); const chatResult = { errorDetails, metadata: { ...metadataFragment, filterReason: FilterReason.Prompt } }; this.turn.setResponse(TurnStatus.PromptFiltered, undefined, baseModelTelemetry.properties.messageId, chatResult); return chatResult; @@ -547,14 +556,14 @@ export class DefaultIntentRequestHandler { } case ChatFetchResponseType.AgentFailedDependency: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); const chatResult = { errorDetails, metadata: metadataFragment }; this.turn.setResponse(TurnStatus.Error, undefined, baseModelTelemetry.properties.messageId, chatResult); return chatResult; } case ChatFetchResponseType.Length: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); const chatResult = { errorDetails, metadata: metadataFragment }; this.turn.setResponse(TurnStatus.Error, undefined, baseModelTelemetry.properties.messageId, chatResult); return chatResult; @@ -562,14 +571,14 @@ export class DefaultIntentRequestHandler { case ChatFetchResponseType.NotFound: // before we had `NotFound`, it would fall into Unknown, so behavior should be consistent case ChatFetchResponseType.Unknown: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); const chatResult = { errorDetails, metadata: metadataFragment }; this.turn.setResponse(TurnStatus.Error, undefined, baseModelTelemetry.properties.messageId, chatResult); return chatResult; } case ChatFetchResponseType.ExtensionBlocked: { const outageStatus = await this._octoKitService.getGitHubOutageStatus(); - const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, (await this._authenticationService.getCopilotToken()).copilotPlan, outageStatus); + const errorDetails = getErrorDetailsFromChatFetchError(fetchResult, await this._getCopilotPlan(), outageStatus); const chatResult = { errorDetails, metadata: metadataFragment }; // This shouldn't happen, only 3rd party extensions should be blocked this.turn.setResponse(TurnStatus.Error, undefined, baseModelTelemetry.properties.messageId, chatResult); diff --git a/extensions/copilot/src/extension/prompt/node/intentDetector.tsx b/extensions/copilot/src/extension/prompt/node/intentDetector.tsx index 16f5f547c67e7..021004cd633aa 100644 --- a/extensions/copilot/src/extension/prompt/node/intentDetector.tsx +++ b/extensions/copilot/src/extension/prompt/node/intentDetector.tsx @@ -260,7 +260,14 @@ export class IntentDetector implements ChatParticipantDetectionProvider { history: Turn[] = [], document?: TextDocumentSnapshot ) { - const endpoint = await this.endpointProvider.getChatEndpoint('copilot-fast'); + let endpoint; + try { + endpoint = await this.endpointProvider.getChatEndpoint('copilot-fast'); + } catch { + // Internal telemetry only — skip if we can't resolve the copilot endpoint + // (e.g., BYOK-only user without sign-in). + return; + } const { messages: currentSelection } = await renderPromptElement(this.instantiationService, endpoint, CurrentSelection, { document }); const { messages: conversationHistory } = await renderPromptElement(this.instantiationService, endpoint, ConversationHistory, { history, priority: 1000 }, undefined, undefined).catch(() => ({ messages: [] })); diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index 3929343df9831..3c1848088ad1f 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -358,11 +358,11 @@ function getQuotaHitMessage(fetchResult: ChatFetchError, copilotPlan: string | u } } -export function getErrorDetailsFromChatFetchError(fetchResult: ChatFetchError, copilotPlan: string, gitHubOutageStatus: GitHubOutageStatus): ChatErrorDetails { +export function getErrorDetailsFromChatFetchError(fetchResult: ChatFetchError, copilotPlan: string | undefined, gitHubOutageStatus: GitHubOutageStatus): ChatErrorDetails { return { code: fetchResult.type, ...getErrorDetailsFromChatFetchErrorInner(fetchResult, copilotPlan, gitHubOutageStatus) }; } -function getErrorDetailsFromChatFetchErrorInner(fetchResult: ChatFetchError, copilotPlan: string, gitHubOutageStatus: GitHubOutageStatus): ChatErrorDetails { +function getErrorDetailsFromChatFetchErrorInner(fetchResult: ChatFetchError, copilotPlan: string | undefined, gitHubOutageStatus: GitHubOutageStatus): ChatErrorDetails { let details: ChatErrorDetails; switch (fetchResult.type) { case ChatFetchResponseType.OffTopic: diff --git a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts index 39dec262899c5..e4663372ce89c 100644 --- a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts @@ -231,7 +231,15 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe } const requestStartTime = Date.now(); - const copilotToken = (await this._authService.getCopilotToken()).token; + let copilotToken: string; + try { + copilotToken = (await this._authService.getCopilotToken()).token; + } catch (e) { + // Not signed in (e.g., BYOK-only user). Copilot model metadata is unavailable. + // BYOK models are resolved separately and don't need this metadata. + this._lastFetchError = e; + return; + } const requestId = generateUuid(); const requestMetadata: RequestMetadata = { type: RequestType.Models, isModelLab: this._isModelLab }; diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 6df31b5eabc2a..f096b196e1eda 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -73,7 +73,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IChatEntitlementService chatEntitlementService: ChatEntitlementService, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, @ILogService private readonly logService: ILogService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, @@ -648,6 +648,12 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr untrusted = true; // by missing workspace trust } else if (state === EnablementState.DisabledWorkspace) { disabledInWorkspace = true; // disabled at workspace level + } else if (this.chatEntitlementService.clientByokEnabled) { + // Enable the extension for BYOK usage even without sign-in + this.logService.info('[chat setup] Enabling chat extension for BYOK usage'); + this.extensionsWorkbenchService.setEnablement([defaultChatExtension], EnablementState.EnabledGlobally).then(() => { + return this.extensionsWorkbenchService.updateRunningExtensions(localize('enableChatExtensionByok', "Enabling AI features")); + }); } } } else { diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index d033549058561..a6581fba975c4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -256,7 +256,9 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } private async doInvoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService, defaultAccountService: IDefaultAccountService): Promise { - const hasByokModels = this.chatEntitlementService.hasByokModels; + // Check both the context key and the workbench model cache for BYOK models. + // The context key may not be set yet due to timing (extension sets it asynchronously). + const hasByokModels = this.chatEntitlementService.hasByokModels || this.hasNonCopilotModels(languageModelsService); if ( (!this.context.state.completed && !hasByokModels) || // Setup not completed (unless BYOK models are configured) this.context.state.disabled || // Extension disabled: run setup to enable @@ -274,6 +276,16 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { return this.doInvokeWithoutSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService); } + private hasNonCopilotModels(languageModelsService: ILanguageModelsService): boolean { + for (const id of languageModelsService.getLanguageModelIds()) { + const model = languageModelsService.lookupLanguageModel(id); + if (model && model.vendor !== 'copilot') { + return true; + } + } + return false; + } + private async doInvokeWithoutSetup(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService): Promise { const requestModel = chatWidgetService.getWidgetBySessionResource(request.sessionResource)?.viewModel?.model.getRequests().at(-1); if (!requestModel) { diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index 8c4d4e223d5b8..633877910204e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -23,6 +23,7 @@ import { isNewUser } from './chatStatus.js'; import product from '../../../../../platform/product/common/product.js'; import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; import { ChatConfiguration } from '../../common/constants.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution { @@ -42,6 +43,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu @IConfigurationService private readonly configurationService: IConfigurationService, @IInlineCompletionsService private readonly completionsService: IInlineCompletionsService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { super(); @@ -74,6 +76,13 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu this._register(this.completionsService.onDidChangeIsSnoozing(() => this.update())); + // Update status bar when BYOK models become available (or are removed) + this._register(this.contextKeyService.onDidChangeContext(e => { + if (e.affectsSome({ has: (key) => key === 'github.copilot.hasByokModels' })) { + this.update(); + } + })); + this._register(this.chatSessionsService.onDidChangeInProgress(() => { const oldSessionsCount = this.runningSessionsCount; this.runningSessionsCount = this.chatSessionsService.getInProgress().reduce((total, item) => total + item.count, 0);