Skip to content

Commit b53fa1a

Browse files
authored
Merge pull request #316811 from microsoft/dev/vritant24/resolveUtilityModels
Avoid blocking Copilot model selection on utility alias resolution
2 parents ba6e707 + d9692f1 commit b53fa1a

3 files changed

Lines changed: 239 additions & 25 deletions

File tree

extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { encodeStatefulMarker } from '../../../platform/endpoint/common/stateful
1818
import { isAnthropicFamily, isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities';
1919
import { AutoChatEndpoint } from '../../../platform/endpoint/node/autoChatEndpoint';
2020
import { IAutomodeService } from '../../../platform/endpoint/node/automodeService';
21-
import { CopilotChatEndpoint } from '../../../platform/endpoint/node/copilotChatEndpoint';
21+
import { CopilotChatEndpoint, CopilotUtilitySmallChatEndpoint } from '../../../platform/endpoint/node/copilotChatEndpoint';
2222
import { IEnvService, isScenarioAutomation } from '../../../platform/env/common/envService';
2323
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
2424
import { IOctoKitService } from '../../../platform/github/common/githubService';
@@ -159,6 +159,25 @@ function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchem
159159
return { configurationSchema: { properties } };
160160
}
161161

162+
const utilityAliasFamilies: readonly ChatEndpointFamily[] = ['copilot-utility-small', 'copilot-utility'];
163+
164+
/**
165+
* Checks whether `endpoint` is the built-in Copilot endpoint for a utility alias.
166+
*/
167+
function isDefaultEndpointForUtilityFamily(family: ChatEndpointFamily, endpoint: IChatEndpoint): boolean {
168+
if (!(endpoint instanceof CopilotChatEndpoint)) {
169+
return false;
170+
}
171+
switch (family) {
172+
case 'copilot-utility-small':
173+
return endpoint.family === CopilotUtilitySmallChatEndpoint.capiFamily;
174+
case 'copilot-utility':
175+
return endpoint.isFallback;
176+
default:
177+
return false;
178+
}
179+
}
180+
162181
/**
163182
* Builds the {@link vscode.LanguageModelChatInformation} entry that publishes a
164183
* utility-family alias (e.g. `copilot-utility-small`) under the copilot vendor.
@@ -228,6 +247,8 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
228247
* underlying endpoint without re-resolving the user setting.
229248
*/
230249
private _utilityAliasEndpoints: Map<string, IChatEndpoint> = new Map();
250+
// Overrides resolved outside model-info publication, reused on the next alias publish.
251+
private readonly _resolvedUtilityEndpoints = new Map<ChatEndpointFamily, { endpoint: IChatEndpoint; baseCount: number }>();
231252
private _lmWrapper: CopilotLanguageModelWrapper;
232253
private _promptBaseCountCache: LanguageModelAccessPromptBaseCountCache;
233254

@@ -282,7 +303,9 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
282303
this._onDidChange.fire();
283304
}));
284305
this._register(this._endpointProvider.onDidModelsRefresh(() => {
285-
// Models have been refreshed from CAPI so we should requery them
306+
// Drop stale overrides; model publication uses defaults until refresh completes.
307+
this._resolvedUtilityEndpoints.clear();
308+
void this._refreshUtilityOverrides();
286309
this._onDidChange.fire();
287310
}));
288311
}
@@ -395,46 +418,76 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
395418
this._currentModels = models;
396419
this._chatEndpoints = chatEndpoints;
397420

398-
await this._registerUtilityAliasModels(models);
421+
this._registerUtilityAliasModels(models, allEndpoints);
399422
return models;
400423
}
401424

402-
private async _registerUtilityAliasModels(models: vscode.LanguageModelChatInformation[]): Promise<void> {
425+
/** Publishes utility aliases without waiting for override resolution. */
426+
private _registerUtilityAliasModels(
427+
models: vscode.LanguageModelChatInformation[],
428+
allEndpoints: readonly IChatEndpoint[],
429+
): void {
403430
this._utilityAliasEndpoints.clear();
404-
const aliasFamilies: ChatEndpointFamily[] = ['copilot-utility-small', 'copilot-utility'];
405431
const session = this._authenticationService.anyGitHubSession;
406432
const requiresAuthorization = session ? { label: session.account.label } : undefined;
407-
for (const family of aliasFamilies) {
408-
let endpoint: IChatEndpoint | undefined;
409-
try {
410-
endpoint = await this._endpointProvider.getChatEndpoint(family);
411-
} catch (err) {
412-
this._logService.warn(`[LanguageModelAccess] Failed to resolve utility alias '${family}': ${err}`);
413-
continue;
414-
}
433+
434+
for (const family of utilityAliasFamilies) {
435+
const cached = this._resolvedUtilityEndpoints.get(family);
436+
const endpoint = cached?.endpoint ?? allEndpoints.find(e => isDefaultEndpointForUtilityFamily(family, e));
415437
if (!endpoint) {
416438
continue;
417439
}
418440
this._utilityAliasEndpoints.set(family, endpoint);
419441

420442
try {
421-
const baseCount = await this._promptBaseCountCache.getBaseCount(endpoint);
422-
// Always publish the alias as an entry under the `copilot` vendor
423-
// — including for BYOK overrides — so workbench consumers that
424-
// resolve these utility aliases directly via
425-
// `vscode.lm.selectChatModels({ vendor: 'copilot', id: '<alias>' })`
426-
// continue to discover a model when the alias is overridden to a
427-
// non-copilot endpoint. The alias entry carries the same
428-
// `requiresAuthorization` metadata as regular copilot entries so
429-
// other extensions can't use it to bypass the underlying
430-
// provider's authorization prompt.
431-
const aliasInfo = buildUtilityAliasModelInfo(family, endpoint, models, baseCount, requiresAuthorization);
443+
// Copilot defaults clone an existing entry; synthesized override aliases need baseCount.
444+
const aliasInfo = buildUtilityAliasModelInfo(family, endpoint, models, cached?.baseCount ?? 0, requiresAuthorization);
432445
this._logService.trace(`[LanguageModelAccess] Publishing alias '${family}' -> ${endpoint.model} (${aliasInfo.synthesized ? 'synthesized' : 'cloned'}, ${endpoint instanceof CopilotChatEndpoint ? 'copilot' : 'override'}).`);
433446
models.push(aliasInfo.info);
434447
} catch (err) {
435448
this._logService.warn(`[LanguageModelAccess] Failed to publish utility alias '${family}' -> ${endpoint.model}; skipping. Error: ${err}`);
436449
}
437450
}
451+
452+
// Override resolution may hang, so keep it off the model-info request path.
453+
void this._refreshUtilityOverrides().catch(err => {
454+
this._logService.warn(`[LanguageModelAccess] Failed to refresh utility overrides: ${err}`);
455+
});
456+
}
457+
458+
/** Resolves configured utility model overrides for the next alias publish. */
459+
private async _refreshUtilityOverrides(): Promise<void> {
460+
let didChange = false;
461+
for (const family of utilityAliasFamilies) {
462+
let resolved: IChatEndpoint | undefined;
463+
try {
464+
resolved = await this._endpointProvider.getChatEndpoint(family);
465+
} catch (err) {
466+
this._logService.warn(`[LanguageModelAccess] Failed to resolve utility alias '${family}' in background: ${err}`);
467+
continue;
468+
}
469+
if (!resolved) {
470+
continue;
471+
}
472+
// Skip when the override resolved to the same endpoint that's
473+
// already published; no alias change needed.
474+
const published = this._utilityAliasEndpoints.get(family);
475+
if (published && published.model === resolved.model && published.modelProvider === resolved.modelProvider) {
476+
continue;
477+
}
478+
let baseCount: number;
479+
try {
480+
baseCount = await this._promptBaseCountCache.getBaseCount(resolved);
481+
} catch (err) {
482+
this._logService.warn(`[LanguageModelAccess] Failed to compute baseCount for utility alias '${family}' -> ${resolved.model}; keeping previously-published alias. Error: ${err}`);
483+
continue;
484+
}
485+
this._resolvedUtilityEndpoints.set(family, { endpoint: resolved, baseCount });
486+
didChange = true;
487+
}
488+
if (didChange) {
489+
this._onDidChange.fire();
490+
}
438491
}
439492

440493
private async _getEndpointForModel(model: vscode.LanguageModelChatInformation) {

extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,23 @@ import * as vscode from 'vscode';
88
import { IChatMLFetcher } from '../../../../platform/chat/common/chatMLFetcher';
99
import { ChatFetchResponseType } from '../../../../platform/chat/common/commonTypes';
1010
import { MockChatMLFetcher } from '../../../../platform/chat/test/common/mockChatMLFetcher';
11+
import { CopilotToken, createTestExtendedTokenInfo } from '../../../../platform/authentication/common/copilotToken';
12+
import { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager';
13+
import { IAutomodeService } from '../../../../platform/endpoint/node/automodeService';
1114
import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';
1215
import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes';
1316
import { CopilotChatEndpoint } from '../../../../platform/endpoint/node/copilotChatEndpoint';
17+
import { IEnvService } from '../../../../platform/env/common/envService';
1418
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
1519
import { IChatEndpoint } from '../../../../platform/networking/common/networking';
1620
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
21+
import { TokenizerType } from '../../../../util/common/tokenizer';
22+
import { DeferredPromise, raceTimeout } from '../../../../util/vs/base/common/async';
1723
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
24+
import { Event } from '../../../../util/vs/base/common/event';
1825
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
1926
import { createExtensionTestingServices } from '../../../test/vscode-node/services';
20-
import { buildUtilityAliasModelInfo, CopilotLanguageModelWrapper } from '../languageModelAccess';
27+
import { buildUtilityAliasModelInfo, CopilotLanguageModelWrapper, LanguageModelAccess } from '../languageModelAccess';
2128

2229

2330
suite('CopilotLanguageModelWrapper', () => {
@@ -137,6 +144,111 @@ suite('CopilotLanguageModelWrapper', () => {
137144
});
138145
});
139146

147+
suite('LanguageModelAccess model info', () => {
148+
test('does not wait for utility alias endpoint resolution', async () => {
149+
const aliasLookupStarted = new DeferredPromise<void>();
150+
const unresolvedAliasEndpoint = new DeferredPromise<IChatEndpoint>();
151+
const endpoint = {
152+
model: 'gpt-4o-mini',
153+
name: 'GPT 4o mini',
154+
family: 'gpt-4o-mini',
155+
version: '2024-07-18',
156+
modelProvider: 'copilot',
157+
modelMaxPromptTokens: 128_000,
158+
maxOutputTokens: 4_096,
159+
supportsToolCalls: true,
160+
supportsVision: false,
161+
supportsPrediction: false,
162+
showInModelPicker: false,
163+
isFallback: false,
164+
tokenizer: TokenizerType.O200K,
165+
urlOrRequestMetadata: '',
166+
} as unknown as IChatEndpoint;
167+
const copilotToken = new CopilotToken(createTestExtendedTokenInfo({ token: 'token', username: 'fake', copilot_plan: 'unknown' }));
168+
const testingServiceCollection = createExtensionTestingServices();
169+
testingServiceCollection.define(ICopilotTokenManager, {
170+
_serviceBrand: undefined,
171+
onDidCopilotTokenRefresh: Event.None,
172+
getCopilotToken: async () => copilotToken,
173+
resetCopilotToken: () => { },
174+
} as unknown as ICopilotTokenManager);
175+
testingServiceCollection.define(IAutomodeService, {
176+
_serviceBrand: undefined,
177+
resolveAutoModeEndpoint: async () => endpoint,
178+
invalidateRouterCache: () => { },
179+
} as unknown as IAutomodeService);
180+
testingServiceCollection.define(IEndpointProvider, {
181+
_serviceBrand: undefined,
182+
onDidModelsRefresh: Event.None,
183+
getAllCompletionModels: async () => [],
184+
getAllChatEndpoints: async () => [endpoint],
185+
getChatEndpoint: async (requestOrFamily: unknown) => {
186+
if (typeof requestOrFamily === 'string') {
187+
void aliasLookupStarted.complete();
188+
return unresolvedAliasEndpoint.p;
189+
}
190+
return endpoint;
191+
},
192+
getEmbeddingsEndpoint: async () => { throw new Error('Not implemented in test'); },
193+
} as unknown as IEndpointProvider);
194+
const accessor = testingServiceCollection.createTestingAccessor();
195+
// Pre-populate the prompt base-count cache so that
196+
// `_provideLanguageModelChatInfo`'s per-endpoint base-count lookup
197+
// resolves synchronously from cache rather than spinning up the
198+
// real tokenizer (which is slow and not relevant to this test).
199+
const extensionContext = accessor.get(IVSCodeExtensionContext);
200+
const baseCountCacheKey = 'lmBaseCount/gpt-4o-mini';
201+
await extensionContext.globalState.update(baseCountCacheKey, { extensionVersion: accessor.get(IEnvService).getVersion(), baseCount: 0 });
202+
const languageModelAccess = accessor.get(IInstantiationService).createInstance(LanguageModelAccess);
203+
try {
204+
const modelInfo = (languageModelAccess as unknown as { _provideLanguageModelChatInfo(options: { silent: boolean }, token: vscode.CancellationToken): Promise<vscode.LanguageModelChatInformation[]> })._provideLanguageModelChatInfo({ silent: true }, CancellationToken.None);
205+
const resolved = await raceTimeout(modelInfo, 2_000);
206+
assert.ok(resolved, 'provideLanguageModelChatInfo did not resolve while utility alias lookup was pending');
207+
assert.deepStrictEqual(resolved.map(model => model.id), ['gpt-4o-mini']);
208+
assert.ok(aliasLookupStarted.isResolved, 'expected utility alias lookup to have been started in the background');
209+
} finally {
210+
languageModelAccess.dispose();
211+
await extensionContext.globalState.update(baseCountCacheKey, undefined);
212+
}
213+
});
214+
215+
test('refreshes utility aliases when an override uses the same model id from another provider', async () => {
216+
const publishedEndpoint = {
217+
model: 'gpt-4o-mini',
218+
modelProvider: 'copilot',
219+
} as IChatEndpoint;
220+
const resolvedEndpoint = {
221+
model: 'gpt-4o-mini',
222+
modelProvider: 'azure',
223+
} as IChatEndpoint;
224+
const testingServiceCollection = createExtensionTestingServices();
225+
testingServiceCollection.define(IEndpointProvider, {
226+
_serviceBrand: undefined,
227+
onDidModelsRefresh: Event.None,
228+
getAllCompletionModels: async () => [],
229+
getAllChatEndpoints: async () => [],
230+
getChatEndpoint: async () => resolvedEndpoint,
231+
getEmbeddingsEndpoint: async () => { throw new Error('Not implemented in test'); },
232+
} as unknown as IEndpointProvider);
233+
const accessor = testingServiceCollection.createTestingAccessor();
234+
const languageModelAccess = accessor.get(IInstantiationService).createInstance(LanguageModelAccess);
235+
const internals = languageModelAccess as unknown as {
236+
_utilityAliasEndpoints: Map<string, IChatEndpoint>;
237+
_resolvedUtilityEndpoints: Map<string, { endpoint: IChatEndpoint; baseCount: number }>;
238+
_promptBaseCountCache: { getBaseCount(endpoint: IChatEndpoint): Promise<number> };
239+
_refreshUtilityOverrides(): Promise<void>;
240+
};
241+
internals._utilityAliasEndpoints.set('copilot-utility-small', publishedEndpoint);
242+
internals._promptBaseCountCache = { getBaseCount: async () => 0 };
243+
try {
244+
await internals._refreshUtilityOverrides();
245+
assert.strictEqual(internals._resolvedUtilityEndpoints.get('copilot-utility-small')?.endpoint, resolvedEndpoint);
246+
} finally {
247+
languageModelAccess.dispose();
248+
}
249+
});
250+
});
251+
140252
suite('buildUtilityAliasModelInfo', () => {
141253

142254
function makeEndpoint(overrides: Partial<IChatEndpoint>): IChatEndpoint {

src/vs/workbench/contrib/chat/test/common/languageModels.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,55 @@ suite('LanguageModels', function () {
207207
assert.ok(vendors.some(v => v.vendor === 'test-vendor'));
208208
assert.ok(vendors.some(v => v.vendor === 'actual-vendor'));
209209
});
210+
211+
test('selectLanguageModels matches by id for copilot vendor models even when isUserSelectable is false', async function () {
212+
// Mirrors how the copilot extension publishes utility aliases such as
213+
// `copilot-utility-small`: under the `copilot` (default) vendor, with
214+
// `isUserSelectable: false`. The workbench's
215+
// `chatToolRiskAssessmentService` resolves them with
216+
// `selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' })`
217+
// and must get a match.
218+
languageModels.deltaLanguageModelChatProviderDescriptors([
219+
{ vendor: 'copilot', displayName: 'Copilot', configuration: undefined, managementCommand: undefined, when: undefined }
220+
], []);
221+
222+
store.add(languageModels.registerLanguageModelProvider('copilot', {
223+
onDidChange: Event.None,
224+
provideLanguageModelChatInfo: async () => {
225+
const modelMetadata: ILanguageModelChatMetadata[] = [
226+
{
227+
extension: nullExtensionDescription.identifier,
228+
name: 'GPT 4o mini',
229+
vendor: 'copilot',
230+
family: 'gpt-4o-mini',
231+
version: '2024-07-18',
232+
id: 'gpt-4o-mini',
233+
maxInputTokens: 100,
234+
maxOutputTokens: 100,
235+
isDefaultForLocation: {}
236+
},
237+
{
238+
extension: nullExtensionDescription.identifier,
239+
name: 'GPT 4o mini',
240+
vendor: 'copilot',
241+
family: 'copilot-utility-small',
242+
version: '2024-07-18',
243+
id: 'copilot-utility-small',
244+
maxInputTokens: 100,
245+
maxOutputTokens: 100,
246+
isDefaultForLocation: {},
247+
isUserSelectable: false
248+
}
249+
];
250+
return modelMetadata.map(m => ({ metadata: m, identifier: `${m.vendor}/${m.id}` }));
251+
},
252+
sendChatRequest: async () => { throw new Error(); },
253+
provideTokenCount: async () => { throw new Error(); }
254+
}));
255+
256+
const result = await languageModels.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' });
257+
assert.deepStrictEqual(result, ['copilot/copilot-utility-small']);
258+
});
210259
});
211260

212261
suite('LanguageModels - When Clause', function () {

0 commit comments

Comments
 (0)