Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
babc769
Enable BYOK if air-gapped scenarios wthiout GitHub auth
dmitrivMS May 20, 2026
1303bfb
Add some tests, update message.
dmitrivMS May 20, 2026
bc94f51
Build break
dmitrivMS May 20, 2026
2788301
Make code resilient to empty embeddings results
dmitrivMS May 20, 2026
7a6ff57
Allow missing Authorization header when secretKey is undefined in net…
dmitrivMS May 20, 2026
19175da
Merge branch 'main' into dev/dmitriv/byok-air-gapped
dmitrivMS May 20, 2026
694e497
Use more netural error message style.
dmitrivMS May 20, 2026
736deda
Merge branch 'main' into dev/dmitriv/byok-air-gapped
dmitrivMS May 20, 2026
f515b2a
Test fixes
dmitrivMS May 20, 2026
9889234
PR feedback
dmitrivMS May 20, 2026
43b5666
Merge remote-tracking branch 'origin/main' into dev/dmitriv/byok-air-…
dmitrivMS May 20, 2026
8dcc7df
Remerge
dmitrivMS May 20, 2026
d2583ce
Restore copilot package.json from main; keep BYOK welcome-view gating
dmitrivMS May 20, 2026
db176d3
Reapply BYOK welcome-view gating against latest main
dmitrivMS May 20, 2026
806b700
Merge branch 'main' into dev/dmitriv/byok-air-gapped
dmitrivMS May 20, 2026
4d63410
PR feedback
dmitrivMS May 20, 2026
3e289e4
Merge branch 'main' into dev/dmitriv/byok-air-gapped
dmitrivMS May 20, 2026
e189d9c
Bug fixes
dmitrivMS May 20, 2026
de7962e
Added test
dmitrivMS May 20, 2026
6031562
Merge branch 'main' into dev/dmitriv/byok-air-gapped
dmitrivMS May 20, 2026
c44fe8d
Merge branch 'main' into dev/dmitriv/byok-air-gapped
dmitrivMS May 20, 2026
978ccca
PR feedback
dmitrivMS May 20, 2026
f94f555
Merge branch 'main' into dev/dmitriv/byok-air-gapped
dmitrivMS May 20, 2026
08c6856
Change default for chat.offlineByok to true, allowing BYOK features w…
dmitrivMS May 20, 2026
972c7a9
Update default for chat.offlineByok to depend on product quality
dmitrivMS May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<unknown>(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();
}
}
Original file line number Diff line number Diff line change
@@ -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<void>();
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<void>();
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<string, unknown> = {}) {
const emitter = new Emitter<{ affectsConfiguration: (key: string) => boolean }>();
const store = new Map<string, unknown>(Object.entries(values));
const configService = {
_serviceBrand: undefined,
getNonExtensionConfig: <T,>(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();
});
});
Loading
Loading