diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5980ac410ccae..5bde2059394a9 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2263,49 +2263,49 @@ "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.individual.expired%", - "when": "github.copilot.interactiveSession.individual.expired" + "when": "github.copilot.interactiveSession.individual.expired && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.enterprise%", - "when": "github.copilot.interactiveSession.enterprise.disabled" + "when": "github.copilot.interactiveSession.enterprise.disabled && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.offline%", - "when": "github.copilot.offline" + "when": "github.copilot.offline && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.invalidToken%", - "when": "github.copilot.interactiveSession.invalidToken" + "when": "github.copilot.interactiveSession.invalidToken && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.rateLimited%", - "when": "github.copilot.interactiveSession.rateLimited" + "when": "github.copilot.interactiveSession.rateLimited && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.gitHubLoginFailed%", - "when": "github.copilot.interactiveSession.gitHubLoginFailed" + "when": "github.copilot.interactiveSession.gitHubLoginFailed && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.contactSupport%", - "when": "github.copilot.interactiveSession.contactSupport" + "when": "github.copilot.interactiveSession.contactSupport && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.chatDisabled%", - "when": "github.copilot.interactiveSession.chatDisabled" + "when": "github.copilot.interactiveSession.chatDisabled && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", diff --git a/extensions/copilot/src/extension/authentication/vscode-node/authentication.contribution.ts b/extensions/copilot/src/extension/authentication/vscode-node/authentication.contribution.ts index 43cfbbfebd072..8d283a40762c2 100644 --- a/extensions/copilot/src/extension/authentication/vscode-node/authentication.contribution.ts +++ b/extensions/copilot/src/extension/authentication/vscode-node/authentication.contribution.ts @@ -50,6 +50,11 @@ class AuthUpgradeAsk extends Disposable { } private async waitForChatEnabled() { + if (!this._authenticationService.anyGitHubSession) { + // BYOK / air-gapped: do not wait for a Copilot token that may never arrive. + return; + } + try { await this._authenticationService.getCopilotToken(); } catch (error) { diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts new file mode 100644 index 0000000000000..8b282e80b4ee7 --- /dev/null +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; +import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; + +const NOTIFICATION_ID = 'copilot.byokUtilityModelHint'; +const UTILITY_MODEL_SETTING = 'chat.utilityModel'; +const UTILITY_SMALL_MODEL_SETTING = 'chat.utilitySmallModel'; + +/** + * Shows a chat input notification in air-gapped BYOK scenarios (no GitHub + * session) when at least one BYOK model is available but the utility model + * settings are still defaults. The default utility models require GitHub + * Copilot access, so without it the utility slots silently fall back and + * degrade the experience until the user points them at a BYOK model. + * + * The notification hides automatically once the user signs in, BYOK models + * disappear, or both utility settings are configured. + */ +export class ByokUtilityModelNotificationContribution extends Disposable { + + private _notification: vscode.ChatInputNotification | undefined; + private _hasByokModels = false; + private _refreshing = false; + + constructor( + @IAuthenticationService private readonly _authService: IAuthenticationService, + @IConfigurationService private readonly _configService: IConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(this._authService.onDidAuthenticationChange(() => this._update())); + this._register(vscode.lm.onDidChangeChatModels(() => this._update())); + this._register(this._configService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(UTILITY_MODEL_SETTING) || e.affectsConfiguration(UTILITY_SMALL_MODEL_SETTING)) { + this._update(); + } + })); + + this._update(); + } + + private async _refreshHasByokModels(): Promise { + if (this._refreshing) { + return; + } + this._refreshing = true; + try { + const models = await vscode.lm.selectChatModels({}); + this._hasByokModels = models.some(m => m.vendor !== 'copilot'); + } catch (err) { + this._logService.warn(`[ByokUtilityModelNotification] Failed to query language models: ${err}`); + } finally { + this._refreshing = false; + } + } + + private async _update(): Promise { + await this._refreshHasByokModels(); + + const signedOut = !this._authService.anyGitHubSession; + const utilityUnset = !this._isUtilityOverrideSet(UTILITY_MODEL_SETTING); + const utilitySmallUnset = !this._isUtilityOverrideSet(UTILITY_SMALL_MODEL_SETTING); + + if (!signedOut || !this._hasByokModels || (!utilityUnset && !utilitySmallUnset)) { + this._hideNotification(); + return; + } + + this._showNotification(utilityUnset, utilitySmallUnset); + } + + private _isUtilityOverrideSet(configKey: string): boolean { + const raw = this._configService.getNonExtensionConfig(configKey); + return typeof raw === 'string' && raw.length > 0; + } + + private _showNotification(utilityUnset: boolean, utilitySmallUnset: boolean): void { + const notification = this._ensureNotification(); + notification.severity = vscode.ChatInputNotificationSeverity.Info; + notification.dismissible = true; + notification.autoDismissOnMessage = false; + + if (utilityUnset && utilitySmallUnset) { + notification.message = vscode.l10n.t('Set BYOK utility models'); + notification.description = vscode.l10n.t('Unlocks full AI features.'); + notification.actions = [ + { label: vscode.l10n.t('Configure'), commandId: 'workbench.action.openSettings', commandArgs: ['chat.utility'] }, + ]; + } else if (utilityUnset) { + notification.message = vscode.l10n.t('Set BYOK utility model'); + notification.description = vscode.l10n.t('Unlocks full AI features.'); + notification.actions = [ + { label: vscode.l10n.t('Configure'), commandId: 'workbench.action.openSettings', commandArgs: [UTILITY_MODEL_SETTING] }, + ]; + } else { + notification.message = vscode.l10n.t('Set BYOK small utility model'); + notification.description = vscode.l10n.t('Unlocks full AI features.'); + notification.actions = [ + { label: vscode.l10n.t('Configure'), commandId: 'workbench.action.openSettings', commandArgs: [UTILITY_SMALL_MODEL_SETTING] }, + ]; + } + + notification.show(); + } + + private _ensureNotification(): vscode.ChatInputNotification { + if (!this._notification) { + this._notification = vscode.chat.createInputNotification(NOTIFICATION_ID); + this._register({ dispose: () => this._notification?.dispose() }); + } + return this._notification; + } + + private _hideNotification(): void { + this._notification?.hide(); + } +} diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts new file mode 100644 index 0000000000000..eecc446340038 --- /dev/null +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { Emitter } from '../../../../util/vs/base/common/event'; + +// ---- vscode mock ----------------------------------------------------------- + +const mockNotification = { + severity: 0, + dismissible: false, + autoDismissOnMessage: false, + message: '', + description: '', + actions: [] as { label: string; commandId: string; commandArgs?: unknown[] }[], + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), +}; + +const onDidChangeChatModelsEmitter = new Emitter(); +const selectChatModelsMock = vi.fn(); + +vi.mock('vscode', () => ({ + ChatInputNotificationSeverity: { Info: 1 }, + chat: { + createInputNotification: vi.fn(() => mockNotification), + }, + lm: { + get onDidChangeChatModels() { return onDidChangeChatModelsEmitter.event; }, + selectChatModels: (...args: unknown[]) => selectChatModelsMock(...args), + }, + l10n: { t: (str: string, ...args: unknown[]) => str.replace(/\{(\d+)\}/g, (_, i) => String(args[Number(i)])) }, +})); + +import { ByokUtilityModelNotificationContribution } from '../byokUtilityModel.contribution'; + +// ---- helpers --------------------------------------------------------------- + +function createAuthService(opts?: { anyGitHubSession?: unknown }) { + const emitter = new Emitter(); + const hasSession = opts && 'anyGitHubSession' in opts; + const authService = { + _serviceBrand: undefined, + anyGitHubSession: hasSession ? opts!.anyGitHubSession : undefined, + onDidAuthenticationChange: emitter.event, + } as unknown as IAuthenticationService; + return { authService, emitter }; +} + +function createConfigService(values: Record = {}) { + const emitter = new Emitter<{ affectsConfiguration: (key: string) => boolean }>(); + const store = new Map(Object.entries(values)); + const configService = { + _serviceBrand: undefined, + getNonExtensionConfig: (key: string) => store.get(key) as T | undefined, + onDidChangeConfiguration: emitter.event, + } as unknown as IConfigurationService; + const set = (key: string, value: unknown) => { + if (value === undefined) { + store.delete(key); + } else { + store.set(key, value); + } + emitter.fire({ affectsConfiguration: (k: string) => k === key }); + }; + return { configService, set }; +} + +const noopLog = { + _serviceBrand: undefined, + trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), show: vi.fn(), +} as unknown as ILogService; + +async function flushAsync() { + // Drain microtasks so async _update() observers complete. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +// ---- tests ----------------------------------------------------------------- + +describe('ByokUtilityModelNotificationContribution', () => { + let contribution: ByokUtilityModelNotificationContribution | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + mockNotification.show.mockClear(); + mockNotification.hide.mockClear(); + mockNotification.message = ''; + mockNotification.description = ''; + mockNotification.actions = []; + selectChatModelsMock.mockResolvedValue([{ vendor: 'ollama', id: 'llama3' }]); + }); + + afterEach(() => { + contribution?.dispose(); + contribution = undefined; + }); + + test('shows notification when signed out + BYOK + both utility settings unset', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).toHaveBeenCalled(); + expect(mockNotification.message).toBe('Set BYOK utility models'); + expect(mockNotification.actions).toHaveLength(1); + expect(mockNotification.actions[0].commandId).toBe('workbench.action.openSettings'); + expect(mockNotification.actions[0].commandArgs).toEqual(['chat.utility']); + }); + + test('shows notification with single action when only chat.utilityModel is unset', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService({ 'chat.utilitySmallModel': 'ollama/llama3' }); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).toHaveBeenCalled(); + expect(mockNotification.message).toBe('Set BYOK utility model'); + expect(mockNotification.actions).toHaveLength(1); + expect(mockNotification.actions[0].commandArgs).toEqual(['chat.utilityModel']); + }); + + test('does not show notification when signed in', async () => { + const { authService } = createAuthService({ anyGitHubSession: { accessToken: 'tok' } }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).not.toHaveBeenCalled(); + }); + + test('does not show notification when no BYOK models are registered', async () => { + selectChatModelsMock.mockResolvedValue([{ vendor: 'copilot', id: 'gpt-4' }]); + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).not.toHaveBeenCalled(); + }); + + test('does not show notification when both utility settings are configured', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService({ + 'chat.utilityModel': 'ollama/llama3', + 'chat.utilitySmallModel': 'ollama/llama3', + }); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).not.toHaveBeenCalled(); + }); + + test('hides notification once both utility settings are configured', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService, set } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + expect(mockNotification.show).toHaveBeenCalled(); + + set('chat.utilityModel', 'ollama/llama3'); + await flushAsync(); + expect(mockNotification.hide).not.toHaveBeenCalled(); // small model still unset → still showing + + set('chat.utilitySmallModel', 'ollama/llama3'); + await flushAsync(); + expect(mockNotification.hide).toHaveBeenCalled(); + }); + + test('hides notification when user signs in', async () => { + const { authService, emitter } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + expect(mockNotification.show).toHaveBeenCalled(); + + (authService as unknown as { anyGitHubSession: unknown }).anyGitHubSession = { accessToken: 'tok' }; + emitter.fire(); + await flushAsync(); + + expect(mockNotification.hide).toHaveBeenCalled(); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index e2c441bfe5207..d4dd32aefb9ef 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -96,6 +96,10 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { } private _fetchAndCacheModels(): void { + if (!this._authenticationService.anyGitHubSession) { + this.logService.info('[CopilotCLIModels] Skipping model fetch since there is no GitHub session'); + return; + } const availableModels = this._availableModels = this._getAvailableModels(); availableModels.then(models => { // Bail out if a newer fetch has superseded this one (e.g. auth changed mid-flight). diff --git a/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts b/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts index a082e4902451c..e43c6ad91dccf 100644 --- a/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts +++ b/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts @@ -31,9 +31,14 @@ export class CompletionsCoreContribution extends Disposable { const unificationStateValue = unificationState.read(reader); const configEnabled = configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsEnableGhCompletionsProvider, experimentationService).read(reader); const extensionUnification = unificationStateValue?.extensionUnification ?? false; + const copilotToken = this._copilotToken.read(reader); let hasInstantiatedProvider = false; - if (unificationStateValue?.codeUnification || extensionUnification || configEnabled || this._copilotToken.read(reader)?.isNoAuthUser) { + // Completions require a Copilot token to call the completions endpoint, so don't + // register the provider in air-gapped / signed-out scenarios — it would just fail + // with GitHubLoginFailedError on every keystroke. + const wantsProvider = unificationStateValue?.codeUnification || extensionUnification || configEnabled || copilotToken?.isNoAuthUser; + if (wantsProvider && copilotToken) { const provider = _copilotInlineCompletionItemProviderService.getOrCreateProvider(); reader.store.add( languages.registerInlineCompletionItemProvider( diff --git a/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx b/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx index 1f11ff3a750ce..db57b8976ff46 100644 --- a/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx +++ b/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx @@ -96,6 +96,9 @@ export class VSCodeAPIContextElement extends PromptElement { const endpoint = await this.endpointProvider.getChatEndpoint(request); - const baseEndpoint = await this.endpointProvider.getChatEndpoint('copilot-utility'); // 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) { return request; @@ -285,6 +284,7 @@ Learn more about [GitHub Copilot](https://docs.github.com/copilot/using-github-c if (this._chatQuotaService.additionalUsageEnabled || !this._chatQuotaService.quotaExhausted) { return request; } + const baseEndpoint = await this.endpointProvider.getChatEndpoint('copilot-utility'); 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 f2f7913837fb1..cf6d03c886e98 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts @@ -85,32 +85,61 @@ export class ConversationFeature implements IExtensionContribution { this._enabled = false; this._activated = false; - // Register Copilot token listener - this.registerCopilotTokenListener(); - const activationBlockerDeferred = new DeferredPromise(); this.activationBlocker = activationBlockerDeferred.p; + + // Activation and chat enablement can be driven by either a Copilot token OR the presence of a BYOK model. + let hasByokModels = false; + const reevaluate = () => { + const hasToken = !!authenticationService.copilotToken; + const shouldActivate = hasToken || hasByokModels; + if (hasToken) { + this.logService.info(`copilot token sku: ${authenticationService.copilotToken?.sku ?? ''}`); + } + this.enabled = shouldActivate; + this.activated = shouldActivate; + if (shouldActivate && !activationBlockerDeferred.isSettled) { + if (hasToken) { + markChatExtGlobal(ChatExtGlobalPerfMark.DidWaitForCopilotToken); + } + activationBlockerDeferred.complete(); + } + }; + if (authenticationService.copilotToken) { this.logService.info(`ConversationFeature: Copilot token already available`); - this.activated = true; - activationBlockerDeferred.complete(); } else { markChatExtGlobal(ChatExtGlobalPerfMark.WillWaitForCopilotToken); - this.logService.info(`ConversationFeature: Waiting for copilot token to activate conversation feature`); + this.logService.info(`ConversationFeature: Waiting for copilot token or BYOK model to activate conversation feature`); } - this._disposables.add(authenticationService.onDidAuthenticationChange(async () => { - const hasSession = !!authenticationService.copilotToken; - this.logService.info(`ConversationFeature: onDidAuthenticationChange has token: ${hasSession}`); - if (hasSession) { - markChatExtGlobal(ChatExtGlobalPerfMark.DidWaitForCopilotToken); - this.activated = true; - } else { - this.activated = false; + const refreshHasByokModels = async () => { + try { + const models = await vscode.lm.selectChatModels({}); + const value = models.some(m => m.vendor !== 'copilot'); + if (value !== hasByokModels) { + hasByokModels = value; + this.logService.info(`ConversationFeature: BYOK models ${value ? 'available' : 'unavailable'}`); + reevaluate(); + } + } catch (e) { + this.logService.warn(`ConversationFeature: failed to query language models: ${e}`); + } + }; + void refreshHasByokModels(); + this._disposables.add(vscode.lm.onDidChangeChatModels(() => void refreshHasByokModels())); + + // Always unblock activation when auth settles; chat enablement is driven by `reevaluate` independently. + // Without this, BYOK-only sessions can deadlock (the BYOK query needs this extension fully activated, + // while activation waits for the BYOK query to set `hasByokModels`). + this._disposables.add(authenticationService.onDidAuthenticationChange(() => { + reevaluate(); + if (!activationBlockerDeferred.isSettled) { + activationBlockerDeferred.complete(); } - - activationBlockerDeferred.complete(); })); + + reevaluate(); } get enabled() { @@ -170,8 +199,8 @@ export class ConversationFeature implements IExtensionContribution { } else { this._searchProviderRegistered = true; - // Don't register for no auth user - if (this.authenticationService.copilotToken?.isNoAuthUser) { + // Don't register for no auth user or BYOK-only users + if (!this.authenticationService.anyGitHubSession || this.authenticationService.copilotToken?.isNoAuthUser) { this.logService.debug('ConversationFeature: Skipping search provider registration - no GitHub session available'); return; } @@ -190,6 +219,13 @@ export class ConversationFeature implements IExtensionContribution { } this._settingsSearchProviderRegistered = true; + + // Don't register for no auth user or or BYOK-only users + if (!this.authenticationService.anyGitHubSession || this.authenticationService.copilotToken?.isNoAuthUser) { + this.logService.debug('ConversationFeature: Skipping settings search provider registration - no GitHub session available'); + return; + } + return vscode.ai.registerSettingsSearchProvider(this.settingsEditorSearchService); } @@ -217,6 +253,11 @@ export class ConversationFeature implements IExtensionContribution { } private registerParticipantDetectionProvider() { + // Many BYOK models are slow and we don't want to risk invalid detection with those, at least for now. + if (!this.authenticationService.anyGitHubSession) { + return; + } + if ('registerChatParticipantDetectionProvider' in vscode.chat) { const provider = this.instantiationService.createInstance(IntentDetector); return vscode.chat.registerChatParticipantDetectionProvider(provider); @@ -344,14 +385,6 @@ export class ConversationFeature implements IExtensionContribution { return disposables; } - private registerCopilotTokenListener() { - this._disposables.add(this.authenticationService.onDidAuthenticationChange(() => { - const chatEnabled = this.authenticationService.copilotToken !== undefined; - this.logService.info(`copilot token sku: ${this.authenticationService.copilotToken?.sku ?? ''}`); - this.enabled = chatEnabled ?? false; - })); - } - private registerTerminalQuickFixProviders() { const isEnabled = () => this.enabled; return combinedDisposable( diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 67e4d1d6e83be..c0f2aa6c6341d 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -572,6 +572,11 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } private async _getToken(): Promise { + if (!this._authenticationService.anyGitHubSession) { + this._logService.warn('[LanguageModelAccess] LanguageModel/Embeddings are not available without auth session'); + return undefined; + } + try { const copilotToken = await this._authenticationService.getCopilotToken(); return copilotToken; diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/conversationFeature.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/conversationFeature.test.ts index 90afbdcb4fab4..4b7ba413009bb 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/conversationFeature.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/conversationFeature.test.ts @@ -135,4 +135,43 @@ suite('Conversation feature test suite', function () { conversationFeature.dispose(); } }); + + test('The feature activates without a Copilot token when a non-copilot (BYOK) language model is available', async function () { + sandbox.stub(vscode.lm, 'selectChatModels').resolves([ + { vendor: 'ollama', id: 'llama3', name: 'llama3', family: 'llama3' } as unknown as vscode.LanguageModelChat + ]); + sandbox.stub(vscode.lm, 'onDidChangeChatModels').returns({ dispose: () => { } }); + + const conversationFeature = instaService.createInstance(ConversationFeature); + try { + // No Copilot token is set; activation and enablement should be driven by BYOK availability. + await conversationFeature.activationBlocker; + assert.deepStrictEqual(conversationFeature.activated, true); + assert.deepStrictEqual(conversationFeature.enabled, true); + } finally { + conversationFeature.dispose(); + } + }); + + test('activationBlocker resolves on an auth change even when the BYOK query never settles', async function () { + // Reproduces the air-gapped startup deadlock: the BYOK detection query (which itself + // activates this extension's language-model providers) can hang until extension + // activation completes, while extension activation is waiting for `activationBlocker`. + // The auth-change event must unconditionally unblock activation regardless of token + // or BYOK availability. + sandbox.stub(vscode.lm, 'selectChatModels').returns(new Promise(() => { /* never resolves */ })); + sandbox.stub(vscode.lm, 'onDidChangeChatModels').returns({ dispose: () => { } }); + + const conversationFeature = instaService.createInstance(ConversationFeature); + try { + const authService = accessor.get(IAuthenticationService) as unknown as { fireAuthenticationChange(source: string): void }; + authService.fireAuthenticationChange('test'); + + await conversationFeature.activationBlocker; + assert.deepStrictEqual(conversationFeature.activated, false); + assert.deepStrictEqual(conversationFeature.enabled, false); + } finally { + conversationFeature.dispose(); + } + }); }); diff --git a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts index 778eab94c7d55..0c4579083e3c2 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts @@ -17,6 +17,7 @@ import { IExtensionContributionFactory, asContributionFactory } from '../../comm import { CompletionsUnificationContribution } from '../../completions/vscode-node/completionsUnificationContribution'; import { ConfigurationMigrationContribution } from '../../configuration/vscode-node/configurationMigration'; import { ContextKeysContribution } from '../../contextKeys/vscode-node/contextKeys.contribution'; +import { ByokUtilityModelNotificationContribution } from '../../chatInputNotification/vscode-node/byokUtilityModel.contribution'; import { ChatInputNotificationContribution } from '../../chatInputNotification/vscode-node/chatInputNotification.contribution'; import { AiMappedEditsContrib } from '../../conversation/vscode-node/aiMappedEditsContrib'; import { ConversationFeature } from '../../conversation/vscode-node/conversationFeature'; @@ -76,6 +77,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(PowerStateLogger), asContributionFactory(ContextKeysContribution), asContributionFactory(ChatInputNotificationContribution), + asContributionFactory(ByokUtilityModelNotificationContribution), asContributionFactory(CopilotDebugCommandContribution), asContributionFactory(DebugCommandsContribution), asContributionFactory(LanguageModelAccess), diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index bc5a3920c3acb..b3eec52354817 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -218,8 +218,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(); + } catch { + // BYOK / air-gapped: no Copilot token available. Continue without one. + } + usernameToScrub = copilotToken?.username ?? this._authenticationService.copilotToken?.username; const fetchResult = await this._fetchAndStreamChat( chatEndpoint, @@ -886,7 +891,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, @@ -965,7 +970,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, @@ -1020,9 +1025,9 @@ 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) { - // If no key is set we error + secretKey ??= copilotToken?.token; + // BYOK endpoints may not need a secret key (e.g., Ollama local), they use getExtraHeaders instead. + if (!secretKey && !chatEndpointInfo.getExtraHeaders) { const urlOrRequestMetadata = stringifyUrlOrRequestMetadata(chatEndpointInfo.urlOrRequestMetadata); this._logService.error(`Failed to send request to ${urlOrRequestMetadata} due to missing key`); sendCommunicationErrorTelemetry(this._telemetryService, `Failed to send request to ${urlOrRequestMetadata} due to missing key`); @@ -1102,7 +1107,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { request: IEndpointBody, baseTelemetryData: TelemetryData, finishedCb: FinishedCallback, - secretKey: string, + secretKey: string | undefined, location: ChatLocation, ourRequestId: string, turnId: string, @@ -1118,7 +1123,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { const intent = locationToIntent(location); const agentInteractionType = interactionTypeOverride ?? intent; const additionalHeaders: Record = { - 'Authorization': `Bearer ${secretKey}`, + ...(secretKey ? { 'Authorization': `Bearer ${secretKey}` } : {}), 'X-Request-Id': ourRequestId, 'OpenAI-Intent': intent, 'X-GitHub-Api-Version': '2025-05-01', @@ -1288,7 +1293,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { request: IEndpointBody, baseTelemetryData: TelemetryData, finishedCb: FinishedCallback, - secretKey: string, + secretKey: string | undefined, location: ChatLocation, ourRequestId: string, nChoices: number | undefined, @@ -1405,7 +1410,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { chatEndpoint: IChatEndpoint, ourRequestId: string, request: IEndpointBody, - secretKey: string, + secretKey: string | undefined, location: ChatLocation, cancellationToken: CancellationToken, userInitiatedRequest?: boolean, diff --git a/extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts b/extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts index 3b4506792e042..0e01cadaf5c05 100644 --- a/extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts +++ b/extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts @@ -213,9 +213,32 @@ export async function renderPromptElementJSON

( // todo@lramos15: We should pass in endpoint provider rather than doing invoke function, but this was easier const endpoint = await instantiationService.invokeFunction(async (accessor) => { const endpointProvider = accessor.get(IEndpointProvider); - return await endpointProvider.getChatEndpoint('copilot-utility'); + try { + return await endpointProvider.getChatEndpoint('copilot-utility'); + } catch { + // JSON rendering issues no chat requests; fall back to a stub so + // tools keep working when no utility model is available. + return createStubPromptEndpoint(); + } }); const hydratedInstaService = instantiationService.createChild(new ServiceCollection([IPromptEndpoint, endpoint])); const renderer = new PromptRendererForJSON(ctor as any, props, tokenOptions, endpoint, hydratedInstaService); return await renderer.renderElementJSON(token); } + +function createStubPromptEndpoint(): IChatEndpoint { + const notImplemented = () => { throw new Error('No utility model available.'); }; + return { + modelMaxPromptTokens: 8192, + name: 'utility', + family: 'unknown', + model: 'copilot-utility', + isFallback: true, + acquireTokenizer: notImplemented, + makeChatRequest: notImplemented, + makeChatRequest2: notImplemented, + createRequestBody: notImplemented, + cloneWithTokenOverride: notImplemented, + processResponseFromChatEndpoint: notImplemented, + } as unknown as IChatEndpoint; +} diff --git a/extensions/copilot/src/extension/prompts/node/base/test/promptRenderer.spec.ts b/extensions/copilot/src/extension/prompts/node/base/test/promptRenderer.spec.ts new file mode 100644 index 0000000000000..82ee938372b72 --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/base/test/promptRenderer.spec.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, test } from 'vitest'; +import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider'; +import { Event } from '../../../../../util/vs/base/common/event'; +import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import { createExtensionUnitTestingServices } from '../../../../test/node/services'; +import { CompositeElement } from '../common'; +import { renderPromptElementJSON } from '../promptRenderer'; + +class ThrowingEndpointProvider implements IEndpointProvider { + declare readonly _serviceBrand: undefined; + readonly onDidModelsRefresh = Event.None; + async getChatEndpoint(): Promise { throw new Error('no utility model'); } + async getEmbeddingsEndpoint(): Promise { throw new Error('not implemented'); } + async getAllChatEndpoints(): Promise { return []; } + async getAllCompletionModels(): Promise { return []; } +} + +describe('renderPromptElementJSON', () => { + test('falls back to a stub endpoint when no utility model is available', async () => { + const testingServiceCollection = createExtensionUnitTestingServices(); + testingServiceCollection.define(IEndpointProvider, new ThrowingEndpointProvider()); + const accessor = testingServiceCollection.createTestingAccessor(); + + const result = await renderPromptElementJSON( + accessor.get(IInstantiationService), + CompositeElement, + {}, + ); + + expect(result.node).toBeDefined(); + }); +}); diff --git a/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx b/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx index 1f4c26d300fed..c2865f146940f 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx @@ -106,6 +106,9 @@ export class NewWorkspacePrompt extends PromptElement 0) { diff --git a/extensions/copilot/src/extension/prompts/node/panel/vscode.tsx b/extensions/copilot/src/extension/prompts/node/panel/vscode.tsx index 1b917dc99af68..4c17b93517398 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/vscode.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/vscode.tsx @@ -140,6 +140,9 @@ export class VscodePrompt extends PromptElement { + // The remote embeddings endpoint requires GitHub authentication. + if (!this._authService.anyGitHubSession) { + return { type: embeddingType, values: [] }; + } // Determine endpoint type: use CAPI for no-auth users, otherwise use GitHub const copilotToken = await this._authService.getCopilotToken(); diff --git a/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts b/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts index b28243818078a..be2b2ba7b4d1c 100644 --- a/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts +++ b/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts @@ -115,6 +115,10 @@ abstract class RelatedInformationProviderEmbeddingsIndex new UrlContent(file.uri, file.content)), EmbeddingsComputeQos.Batch, token) + this.getEmbeddingsForFiles(authToken, embeddingType, files.map(file => new UrlContent(file.uri, file.content)), EmbeddingsComputeQos.Batch, token) ]), token); + if (!queryEmbedding) { + return files.map(() => []); + } + return this.computeChunkScores(fileChunksAndEmbeddings, queryEmbedding); } - private async computeEmbeddings(embeddingType: EmbeddingType, str: string, inputType: EmbeddingInputType, token: CancellationToken): Promise { + private async computeEmbeddings(embeddingType: EmbeddingType, str: string, inputType: EmbeddingInputType, token: CancellationToken): Promise { const embeddings = await this._embeddingsComputer.computeEmbeddings(embeddingType, [str], { inputType }, new TelemetryCorrelationId('UrlChunkEmbeddingsIndex::computeEmbeddings'), token); return embeddings.values[0]; } - private async getEmbeddingsForFiles(embeddingType: EmbeddingType, files: readonly UrlContent[], qos: EmbeddingsComputeQos, token: CancellationToken): Promise<(readonly FileChunkWithEmbedding[])[]> { + private async getEmbeddingsForFiles(authToken: string, embeddingType: EmbeddingType, files: readonly UrlContent[], qos: EmbeddingsComputeQos, token: CancellationToken): Promise<(readonly FileChunkWithEmbedding[])[]> { if (!files.length) { return []; } const batchInfo = new ComputeBatchInfo(); - this._logService.trace(`urlChunkEmbeddingsIndex: Getting auth token `); - const authToken = await this.tryGetAuthToken(); - if (!authToken) { - this._logService.error('urlChunkEmbeddingsIndex: Unable to get auth token'); - throw new Error('Unable to get auth token'); - } - const result = await Promise.all(files.map(async file => { const result = await this.getChunksAndEmbeddings(authToken, embeddingType, file, batchInfo, qos, token); - if (!result) { - return []; - } - return result; + return result ?? []; })); return result; } @@ -155,4 +158,4 @@ class SimpleUrlContentCache { const hash = await content.getContentHash(); this._cache.set(content.uri, { hash, value }); } -} \ No newline at end of file +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 10debd9df0cb6..39fb781ce40cd 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1621,7 +1621,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.OfflineByok]: { type: 'boolean', description: nls.localize('chat.offlineByok', "Experimental: enable BYOK chat features without GitHub sign-in."), - default: false, + default: product.quality !== 'stable', scope: ConfigurationScope.WINDOW, included: false, }, diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index e209805841136..9992894016633 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -363,7 +363,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: 'workbench.action.chat.triggerSetupFromAccounts', - title: localize2('triggerChatSetupFromAccounts', "Sign in to use AI features..."), + title: localize2('triggerChatSetupFromAccounts', "Sign in to use GitHub Copilot..."), menu: { id: MenuId.AccountsContext, group: '2_copilot', @@ -371,7 +371,6 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), ChatContextKeys.Setup.completed.negate(), - ChatEntitlementContextKeys.hasByokModels.negate(), ChatContextKeys.Entitlement.signedOut ) } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 1493a525227bb..55f6f89c49b8b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -518,7 +518,9 @@ export class ChatStatusDashboard extends DomWidget { const newUser = isNewUser(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 && !hasByokModels; + // Keep the Sign-in entry visible even when BYOK models are present so air-gapped + // users can still authenticate to unlock the full Copilot experience. + const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown; if (!(newUser || signedOut || disabled)) { return; } @@ -537,7 +539,7 @@ export class ChatStatusDashboard extends DomWidget { } else if (disabled) { descriptionText = localize('enableDescription', "Enable Copilot to use AI features."); } else { - descriptionText = localize('signInDescription', "Sign in to use Copilot AI features."); + descriptionText = localize('signInDescription', "Sign in to use GitHub Copilot AI features."); } let buttonLabel: string; @@ -548,7 +550,7 @@ export class ChatStatusDashboard extends DomWidget { } else if (disabled) { buttonLabel = localize('enableCopilotButton', "Enable AI Features"); } else { - buttonLabel = localize('signInToUseAIFeatures', "Sign in to use AI Features"); + buttonLabel = localize('signInToUseAIFeatures', "Sign in to use GitHub Copilot"); } let commandId: string; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index e7239ff59de9c..1a21aec2e9a80 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -177,8 +177,9 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu } } - // Signed out - else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.hasByokModels) { + // Signed out — keep showing Sign-in affordance even when BYOK models are present + // so air-gapped users can still authenticate to unlock the full Copilot experience. + else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) { return this.getSetupEntryProps(); }