diff --git a/extensions/copilot/src/extension/byok/common/byokProvider.ts b/extensions/copilot/src/extension/byok/common/byokProvider.ts index 7bfe1c0722894..a091a38ea905a 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 { @@ -159,16 +156,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/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 3cf6f8e7bce99..043da0c24f266 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts @@ -2,15 +2,13 @@ * 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 { IAuthenticationService } from '../../../platform/authentication/common/authentication'; -import { ICAPIClientService } from '../../../platform/endpoint/common/capiClient'; +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'; 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'; @@ -22,6 +20,19 @@ import { OAIBYOKLMProvider } from './openAIProvider'; import { OpenRouterLMProvider } from './openRouterProvider'; 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; @@ -32,64 +43,97 @@ 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); - })); + void this._registerProviders().catch(err => { + this._byokProvidersRegistered = false; + this._logService.error(err instanceof Error ? err : String(err), 'BYOK: Failed to register providers.'); + }); } - private async _authChange(authService: IAuthenticationService, instantiationService: IInstantiationService) { - const byokEnabled = authService.copilotToken && isBYOKEnabled(authService.copilotToken, this._capiClientService); + private async _registerProviders() { + if (this._byokProvidersRegistered) { + return; + } - if (!byokEnabled && this._byokProvidersRegistered) { - this._logService.info('BYOK: Disabling BYOK providers due to account change.'); - this._byokRegistrations.clear(); - this._providers.clear(); - this._byokProvidersRegistered = false; + this._byokProvidersRegistered = true; + const instantiationService = this._instantiationService; + + // 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; } - 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._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)); + 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(() => { + void this._updateHasByokModelsContext().catch(err => { + this._logService.error(err instanceof Error ? err : String(err), 'BYOK: Failed to update BYOK models context.'); + }); + })); + } + + async _updateHasByokModelsContext(): Promise { + 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(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> { - 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; } -} \ No newline at end of file +} 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/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 942db76b1acea..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; + 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..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'; @@ -36,7 +37,8 @@ const LANGUAGE_MODELS_ENTITLEMENT_PRECONDITION = ContextKeyExpr.and(ChatContextK ChatContextKeys.Entitlement.planProPlus, ChatContextKeys.Entitlement.planBusiness, ChatContextKeys.Entitlement.planEnterprise, - ChatContextKeys.Entitlement.internal + ChatContextKeys.Entitlement.internal, + ChatEntitlementContextKeys.clientByokEnabled )); Registry.as(EditorExtensions.EditorPane).registerEditorPane( 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/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 ffc5b0eca5ef3..a6581fba975c4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -256,14 +256,18 @@ 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 { + // 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 || // 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.anonymous && // unless anonymous access is enabled + !hasByokModels // unless BYOK models are configured ) ) { return this.doInvokeWithSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService, defaultAccountService); @@ -272,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/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index eb6ff2ae0438e..2f03039b46f77 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -198,7 +198,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.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 e30045006ea64..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 || 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; // signed out or not-yet-signed-up users can only use Chat if anonymous access 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 bbce59557f403..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); @@ -147,7 +156,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.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 78b8aea372ccf..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) { + 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 1c36aa549cd97..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; + 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 e649818fb389a..9584ba3477cf0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1010,7 +1010,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 40e38cd8e0699..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', 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..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'); @@ -161,6 +165,7 @@ export interface IChatEntitlementService { readonly previewFeaturesDisabled: boolean; readonly clientByokEnabled: boolean; + readonly hasByokModels: boolean; readonly organisations: string[] | undefined; readonly isInternal: boolean; @@ -313,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); @@ -419,6 +426,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 {