Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';6import type { AuthenticationSession } from 'vscode';7import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication';8import { ConfigKey } from '../../../../../platform/configuration/common/configurationService';9import { DefaultsOnlyConfigurationService } from '../../../../../platform/configuration/common/defaultsOnlyConfigurationService';10import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';11import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';12import { ILogService } from '../../../../../platform/log/common/logService';13import { Emitter } from '../../../../../util/vs/base/common/event';14import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';15import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';16import { createExtensionUnitTestingServices } from '../../../../test/node/services';17import { CopilotCLIModels, type CopilotCLIModelInfo, type ICopilotCLISDK } from '../copilotCli';1819function createMockExtensionContext(): IVSCodeExtensionContext {20const state = new Map<string, unknown>();21return {22extensionPath: '/mock',23globalState: {24get: <T>(key: string, defaultValue?: T) => (state.get(key) as T) ?? defaultValue,25update: async (key: string, value: unknown) => { state.set(key, value); },26keys: () => [...state.keys()]27},28workspaceState: {29get: () => ({}),30update: async () => { },31keys: () => []32}33} as unknown as IVSCodeExtensionContext;34}3536const FAKE_MODELS: CopilotCLIModelInfo[] = [37{ id: 'gpt-4o', name: 'GPT-4o', maxContextWindowTokens: 128000, supportsVision: true },38{ id: 'gpt-3.5', name: 'GPT-3.5', maxContextWindowTokens: 16000, supportsVision: false },39];4041function createMockSDK(models: CopilotCLIModelInfo[] = FAKE_MODELS): ICopilotCLISDK {42return {43_serviceBrand: undefined,44getPackage: vi.fn(async () => ({45getAvailableModels: vi.fn(async () => models.map(m => ({46id: m.id,47name: m.name,48billing: m.multiplier !== undefined ? { multiplier: m.multiplier } : undefined,49capabilities: {50limits: {51max_prompt_tokens: m.maxInputTokens,52max_output_tokens: m.maxOutputTokens,53max_context_window_tokens: m.maxContextWindowTokens,54},55supports: { vision: m.supportsVision }56}57}))),58})),59getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),60getRequestId: vi.fn(() => undefined),61setRequestId: vi.fn(),62} as unknown as ICopilotCLISDK;63}6465class MockAuthenticationService {66private readonly _onDidAuthenticationChange = new Emitter<void>();67readonly onDidAuthenticationChange = this._onDidAuthenticationChange.event;6869private _anyGitHubSession: AuthenticationSession | undefined;7071constructor(hasSession: boolean) {72this._anyGitHubSession = hasSession73? { id: 'test', accessToken: 'token', scopes: [], account: { id: 'user', label: 'User' } }74: undefined;75}7677get anyGitHubSession(): AuthenticationSession | undefined {78return this._anyGitHubSession;79}8081setSession(session: AuthenticationSession | undefined): void {82this._anyGitHubSession = session;83}8485fireAuthenticationChange(): void {86this._onDidAuthenticationChange.fire();87}8889dispose(): void {90this._onDidAuthenticationChange.dispose();91}92}93class MockConfigurationService extends InMemoryConfigurationService {94constructor() {95super(new DefaultsOnlyConfigurationService());96}97}9899describe('CopilotCLIModels', () => {100const disposables = new DisposableStore();101let logService: ILogService;102103beforeEach(() => {104const services = disposables.add(createExtensionUnitTestingServices());105const accessor = services.createTestingAccessor();106logService = accessor.get(ILogService);107accessor.get(IInstantiationService);108});109110afterEach(() => {111disposables.clear();112});113114function createModels(options: { hasSession?: boolean; sdk?: ICopilotCLISDK; configService?: MockConfigurationService } = {}): { models: CopilotCLIModels; auth: MockAuthenticationService; configService: MockConfigurationService } {115const auth = new MockAuthenticationService(options.hasSession ?? true);116const sdk = options.sdk ?? createMockSDK();117const extensionContext = createMockExtensionContext();118const configService = options.configService ?? new MockConfigurationService();119120const models = new CopilotCLIModels(121sdk,122extensionContext,123logService,124auth as unknown as IAuthenticationService,125configService126);127disposables.add(models);128disposables.add({ dispose: () => auth.dispose() });129return { models, auth, configService };130}131132describe('getModels', () => {133it('returns empty array when no GitHub session exists', async () => {134const { models } = createModels({ hasSession: false });135136const result = await models.getModels();137138expect(result).toEqual([]);139});140141it('returns models when GitHub session exists', async () => {142const { models } = createModels({ hasSession: true });143144const result = await models.getModels();145146expect(result.length).toBe(2);147expect(result[0].id).toBe('gpt-4o');148expect(result[1].id).toBe('gpt-3.5');149});150151it('returns cached models on subsequent calls', async () => {152const sdk = createMockSDK();153const { models } = createModels({ hasSession: true, sdk });154155const first = await models.getModels();156const second = await models.getModels();157158expect(first).toBe(second);159// getPackage is called during constructor's eager fetch and at most once more160expect(sdk.getPackage).toHaveBeenCalledTimes(1);161});162});163164describe('resolveModel', () => {165it('returns undefined when no GitHub session exists', async () => {166const { models } = createModels({ hasSession: false });167168const result = await models.resolveModel('gpt-4o');169170expect(result).toBeUndefined();171});172173it('resolves model by id (case-insensitive)', async () => {174const { models } = createModels({ hasSession: true });175176expect(await models.resolveModel('GPT-4O')).toBe('gpt-4o');177expect(await models.resolveModel('gpt-4o')).toBe('gpt-4o');178});179180it('resolves model by name (case-insensitive)', async () => {181const { models } = createModels({ hasSession: true });182183expect(await models.resolveModel('GPT-3.5')).toBe('gpt-3.5');184});185186it('returns undefined for unknown model', async () => {187const { models } = createModels({ hasSession: true });188189expect(await models.resolveModel('nonexistent-model')).toBeUndefined();190});191192it('resolves "auto" without querying SDK models', async () => {193const { models } = createModels({ hasSession: false });194195// Even without a session, 'auto' resolves to itself196expect(await models.resolveModel('auto')).toBe('auto');197expect(await models.resolveModel('Auto')).toBe('Auto');198expect(await models.resolveModel('AUTO')).toBe('AUTO');199});200});201202describe('getDefaultModel', () => {203it('returns undefined when no GitHub session exists', async () => {204const { models } = createModels({ hasSession: false });205206const result = await models.getDefaultModel();207208expect(result).toBeUndefined();209});210211it('returns first model when no preference is stored', async () => {212const { models } = createModels({ hasSession: true });213214const result = await models.getDefaultModel();215216expect(result).toBe('gpt-4o');217});218219it('returns preferred model when preference is stored', async () => {220const { models } = createModels({ hasSession: true });221222await models.setDefaultModel('gpt-3.5');223const result = await models.getDefaultModel();224225expect(result).toBe('gpt-3.5');226});227228it('falls back to first model when stored preference is invalid', async () => {229const { models } = createModels({ hasSession: true });230231await models.setDefaultModel('nonexistent-model');232const result = await models.getDefaultModel();233234expect(result).toBe('gpt-4o');235});236});237238describe('onDidAuthenticationChange', () => {239it('propagates authentication change events to language model provider', async () => {240const sdk = createMockSDK();241const auth = new MockAuthenticationService(true);242disposables.add({ dispose: () => auth.dispose() });243const extensionContext = createMockExtensionContext();244245const models = new CopilotCLIModels(246sdk,247extensionContext,248logService,249auth as unknown as IAuthenticationService,250new MockConfigurationService()251);252disposables.add(models);253254// Wait for the eager model fetch to complete255await models.getModels();256257// Subscribe to the change event via registerLanguageModelChatProvider258// and capture the provider's event259let providerOnChangeEvent: any;260const lmMock = {261registerLanguageModelChatProvider: (_id: string, provider: any) => {262providerOnChangeEvent = provider.onDidChangeLanguageModelChatInformation;263return { dispose: () => { } };264}265};266models.registerLanguageModelChatProvider(lmMock as any);267268// Now subscribe to the captured event269let fired = false;270disposables.add(providerOnChangeEvent(() => { fired = true; }));271272// Fire auth change — should propagate through _onDidChange273auth.fireAuthenticationChange();274275expect(fired).toBe(true);276});277278it('returns models after session becomes available', async () => {279const { models, auth } = createModels({ hasSession: false });280281// No session: no models282expect(await models.getModels()).toEqual([]);283284// Set session and verify models are now available285auth.setSession({ id: 'test', accessToken: 'token', scopes: [], account: { id: 'user', label: 'User' } });286const result = await models.getModels();287expect(result.length).toBe(2);288});289290it('invalidates model cache on auth change', async () => {291const sdk = createMockSDK();292const { models, auth } = createModels({ hasSession: true, sdk });293294// Initial fetch295await models.getModels();296const initialCallCount = (sdk.getPackage as ReturnType<typeof vi.fn>).mock.calls.length;297298// Fire auth change to invalidate the cache299auth.fireAuthenticationChange();300301// Next getModels() call should re-fetch from the SDK302await models.getModels();303expect((sdk.getPackage as ReturnType<typeof vi.fn>).mock.calls.length).toBe(initialCallCount + 1);304});305306it('returns fresh models after auth change', async () => {307const updatedModels: CopilotCLIModelInfo[] = [308{ id: 'claude-4', name: 'Claude 4', maxContextWindowTokens: 200000, supportsVision: true },309];310let callCount = 0;311const sdk = {312_serviceBrand: undefined,313getPackage: vi.fn(async () => ({314getAvailableModels: vi.fn(async () => {315const source = callCount++ === 0 ? FAKE_MODELS : updatedModels;316return source.map(m => ({317id: m.id,318name: m.name,319billing: m.multiplier !== undefined ? { multiplier: m.multiplier } : undefined,320capabilities: {321limits: {322max_prompt_tokens: m.maxInputTokens,323max_output_tokens: m.maxOutputTokens,324max_context_window_tokens: m.maxContextWindowTokens,325},326supports: { vision: m.supportsVision }327},328}));329}),330})),331getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),332getRequestId: vi.fn(() => undefined),333setRequestId: vi.fn(),334} as unknown as ICopilotCLISDK;335336const { models, auth } = createModels({ hasSession: true, sdk });337338// First fetch returns FAKE_MODELS339const first = await models.getModels();340expect(first.length).toBe(2);341expect(first[0].id).toBe('gpt-4o');342343// Auth change invalidates cache344auth.fireAuthenticationChange();345346// Next fetch returns updated models347const second = await models.getModels();348expect(second.length).toBe(1);349expect(second[0].id).toBe('claude-4');350});351});352353describe('provideLanguageModelChatInformation', () => {354function createLmMock() {355let capturedProvider: any;356return {357mock: {358registerLanguageModelChatProvider: (_id: string, provider: any) => {359capturedProvider = provider;360return { dispose: () => { } };361}362},363getProvider: () => capturedProvider,364};365}366367it('always includes auto model in results', async () => {368const { models } = createModels({ hasSession: true });369const lm = createLmMock();370models.registerLanguageModelChatProvider(lm.mock as any);371372// Wait for the eager fetch to complete373await models.getModels();374// Allow the _fetchAndCacheModels .then() to run375await new Promise(r => setTimeout(r, 0));376377const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);378expect(result[0]).toEqual(expect.objectContaining({ id: 'auto', name: 'Auto' }));379});380381it('returns only auto when not authenticated', async () => {382const { models } = createModels({ hasSession: false });383const lm = createLmMock();384models.registerLanguageModelChatProvider(lm.mock as any);385386// Allow microtasks to settle (the eager fetch will fail/return empty)387await new Promise(r => setTimeout(r, 0));388389const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);390expect(result).toEqual([expect.objectContaining({ id: 'auto', name: 'Auto' })]);391});392393it('returns only auto while models are still being fetched', async () => {394// Create an SDK that never resolves395let resolveModels!: (models: any[]) => void;396const sdk = {397_serviceBrand: undefined,398getPackage: vi.fn(async () => ({399getAvailableModels: vi.fn(() => new Promise(resolve => { resolveModels = resolve; })),400})),401getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),402getRequestId: vi.fn(() => undefined),403} as unknown as ICopilotCLISDK;404405const { models } = createModels({ hasSession: true, sdk });406const lm = createLmMock();407models.registerLanguageModelChatProvider(lm.mock as any);408409// Models are still pending — should only get auto410const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);411expect(result).toEqual([expect.objectContaining({ id: 'auto', name: 'Auto' })]);412413// Flush microtasks so getPackage()/getAuthInfo() resolve and getAvailableModels is called,414// which captures resolveModels.415await new Promise(r => setTimeout(r, 0));416417// Now resolve the models and let promises settle418resolveModels(FAKE_MODELS.map(m => ({419id: m.id, name: m.name,420capabilities: { limits: { max_context_window_tokens: m.maxContextWindowTokens, max_prompt_tokens: m.maxInputTokens, max_output_tokens: m.maxOutputTokens }, supports: { vision: m.supportsVision } },421})));422await new Promise(r => setTimeout(r, 0));423424const afterResolve = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);425expect(afterResolve.length).toBe(3); // auto + 2 models426expect(afterResolve[0]).toEqual(expect.objectContaining({ id: 'auto' }));427expect(afterResolve[1]).toEqual(expect.objectContaining({ id: 'gpt-4o' }));428expect(afterResolve[2]).toEqual(expect.objectContaining({ id: 'gpt-3.5' }));429});430431it('returns full model list with auto prepended after fetch completes', async () => {432const { models } = createModels({ hasSession: true });433const lm = createLmMock();434models.registerLanguageModelChatProvider(lm.mock as any);435436// Wait for the eager fetch to complete437await models.getModels();438await new Promise(r => setTimeout(r, 0));439440const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);441expect(result.length).toBe(3); // auto + 2 models442expect(result.map((m: any) => m.id)).toEqual(['auto', 'gpt-4o', 'gpt-3.5']);443});444445it('resets to auto-only after auth change, then recovers', async () => {446const { models, auth } = createModels({ hasSession: true });447const lm = createLmMock();448models.registerLanguageModelChatProvider(lm.mock as any);449450// Wait for initial fetch451await models.getModels();452await new Promise(r => setTimeout(r, 0));453454const beforeAuthChange = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);455expect(beforeAuthChange.length).toBe(3);456457// Fire auth change — caches are cleared458auth.fireAuthenticationChange();459460// Immediately after auth change, _resolvedModelInfos is cleared but re-fetch is in flight.461// Before the re-fetch settles, we should get just auto.462// (The re-fetch is async so hasn't settled yet in the same microtask.)463const duringRefresh = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);464// Could be auto-only or already refreshed depending on timing; at minimum auto is present465expect(duringRefresh[0]).toEqual(expect.objectContaining({ id: 'auto' }));466467// Let the re-fetch settle468await models.getModels();469await new Promise(r => setTimeout(r, 0));470471const afterRefresh = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);472expect(afterRefresh.length).toBe(3);473expect(afterRefresh[0]).toEqual(expect.objectContaining({ id: 'auto' }));474});475476it('fires onDidChange when models become available', async () => {477let resolveModels!: (models: any[]) => void;478const sdk = {479_serviceBrand: undefined,480getPackage: vi.fn(async () => ({481getAvailableModels: vi.fn(() => new Promise(resolve => { resolveModels = resolve; })),482})),483getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),484getRequestId: vi.fn(() => undefined),485} as unknown as ICopilotCLISDK;486487const { models } = createModels({ hasSession: true, sdk });488const lm = createLmMock();489models.registerLanguageModelChatProvider(lm.mock as any);490491let changeCount = 0;492disposables.add(lm.getProvider().onDidChangeLanguageModelChatInformation(() => { changeCount++; }));493494// Flush microtasks so getPackage()/getAuthInfo() resolve and getAvailableModels is called,495// which captures resolveModels.496await new Promise(r => setTimeout(r, 0));497498// Resolve models499resolveModels(FAKE_MODELS.map(m => ({500id: m.id, name: m.name,501capabilities: { limits: { max_context_window_tokens: m.maxContextWindowTokens, max_prompt_tokens: m.maxInputTokens, max_output_tokens: m.maxOutputTokens }, supports: { vision: m.supportsVision } },502})));503await new Promise(r => setTimeout(r, 0));504505expect(changeCount).toBeGreaterThan(0);506});507});508509describe('CLIAutoModelEnabled setting', () => {510function createLmMock() {511let capturedProvider: any;512return {513mock: {514registerLanguageModelChatProvider: (_id: string, provider: any) => {515capturedProvider = provider;516return { dispose: () => { } };517}518},519getProvider: () => capturedProvider,520};521}522523it('omits auto model from resolved list when disabled', async () => {524const configService = new MockConfigurationService();525await configService.setConfig(ConfigKey.Advanced.CLIAutoModelEnabled, false);526const { models } = createModels({ hasSession: true, configService });527const lm = createLmMock();528models.registerLanguageModelChatProvider(lm.mock as any);529530await models.getModels();531await new Promise(r => setTimeout(r, 0));532533const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);534expect(result.every((m: any) => m.id !== 'auto')).toBe(true);535expect(result.length).toBe(2);536expect(result[0]).toEqual(expect.objectContaining({ id: 'gpt-4o' }));537});538539it('returns empty list when not authenticated and auto model disabled', async () => {540const configService = new MockConfigurationService();541await configService.setConfig(ConfigKey.Advanced.CLIAutoModelEnabled, false);542const { models } = createModels({ hasSession: false, configService });543const lm = createLmMock();544models.registerLanguageModelChatProvider(lm.mock as any);545546await new Promise(r => setTimeout(r, 0));547548const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);549expect(result).toEqual([]);550});551552it('resolveModel does not short-circuit auto when disabled', async () => {553const configService = new MockConfigurationService();554await configService.setConfig(ConfigKey.Advanced.CLIAutoModelEnabled, false);555const { models } = createModels({ hasSession: true, configService });556557// With the setting disabled, 'auto' is not a known model so resolveModel returns undefined558expect(await models.resolveModel('auto')).toBeUndefined();559});560561it('includes auto model when setting is enabled (default)', async () => {562const { models } = createModels({ hasSession: true });563const lm = createLmMock();564models.registerLanguageModelChatProvider(lm.mock as any);565566await models.getModels();567await new Promise(r => setTimeout(r, 0));568569const result = await lm.getProvider().provideLanguageModelChatInformation({}, undefined);570expect(result[0]).toEqual(expect.objectContaining({ id: 'auto' }));571expect(result.length).toBe(3); // auto + 2 models572});573});574575describe('SDK error handling', () => {576it('returns empty array when SDK getAvailableModels throws', async () => {577const sdk = {578_serviceBrand: undefined,579getPackage: vi.fn(async () => ({580getAvailableModels: vi.fn(async () => { throw new Error('Network error'); }),581})),582getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),583getRequestId: vi.fn(() => undefined),584setRequestId: vi.fn(),585} as unknown as ICopilotCLISDK;586587const { models } = createModels({ hasSession: true, sdk });588589const result = await models.getModels();590591expect(result).toEqual([]);592});593});594});595596597