Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.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, describe, expect, it } from 'vitest';6import type * as vscode from 'vscode';7import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider';8import { IChatEndpoint } from '../../../../../platform/networking/common/networking';9import { Emitter } from '../../../../../util/vs/base/common/event';10import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';11import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';12import { createExtensionUnitTestingServices } from '../../../../test/node/services';13import { ClaudeCodeModels, isEffortLevel } from '../claudeCodeModels';14import { tryParseClaudeModelId } from '../claudeModelId';1516/**17* Creates a minimal mock IChatEndpoint with required properties for testing18*/19function createMockEndpoint(overrides: {20model: string;21name: string;22family: string;23showInModelPicker?: boolean;24multiplier?: number;25apiType?: string;26modelProvider?: string;27supportsReasoningEffort?: string[];28}): IChatEndpoint {29const isAnthropic = overrides.modelProvider === undefined || overrides.modelProvider === 'Anthropic';30return {31model: overrides.model,32name: overrides.name,33family: overrides.family,34version: '1.0',35showInModelPicker: overrides.showInModelPicker ?? true,36multiplier: overrides.multiplier,37modelProvider: overrides.modelProvider ?? 'Anthropic',38apiType: overrides.apiType ?? (isAnthropic ? 'messages' : 'chatCompletions'),39// Required properties with sensible defaults40maxOutputTokens: 4096,41supportsToolCalls: true,42supportsVision: false,43supportsPrediction: false,44supportsReasoningEffort: overrides.supportsReasoningEffort,45isDefault: false,46isFallback: false,47policy: 'enabled',48urlOrRequestMetadata: 'mock://endpoint',49modelMaxPromptTokens: 128000,50tokenizer: 'cl100k_base',51acquireTokenizer: () => ({ encode: () => [], free: () => { } }) as any,52processResponseFromChatEndpoint: () => Promise.resolve({} as any),53acceptChatPolicy: () => Promise.resolve(true),54fetchChatResponse: () => Promise.resolve({} as any),55} as unknown as IChatEndpoint;56}5758/**59* Mock endpoint provider that supports firing onDidModelsRefresh and updating endpoints.60*/61class RefreshableMockEndpointProvider implements IEndpointProvider {62declare readonly _serviceBrand: undefined;63private readonly _onDidModelsRefresh = new Emitter<void>();64readonly onDidModelsRefresh = this._onDidModelsRefresh.event;65private _endpoints: IChatEndpoint[];6667constructor(endpoints: IChatEndpoint[]) {68this._endpoints = endpoints;69}7071setEndpoints(endpoints: IChatEndpoint[]): void {72this._endpoints = endpoints;73}7475fireRefresh(): void {76this._onDidModelsRefresh.fire();77}7879async getAllChatEndpoints(): Promise<IChatEndpoint[]> {80return this._endpoints;81}8283getChatEndpoint(): Promise<IChatEndpoint> {84throw new Error('Not implemented');85}86getEmbeddingsEndpoint(): Promise<any> {87throw new Error('Not implemented');88}89getAllCompletionModels(): Promise<any[]> {90throw new Error('Not implemented');91}92}9394describe('ClaudeCodeModels', () => {95const store = new DisposableStore();9697afterEach(() => {98store.clear();99});100101function createServiceWithRefreshableEndpoints(102endpoints: IChatEndpoint[],103): { service: ClaudeCodeModels; provider: RefreshableMockEndpointProvider } {104const endpointProvider = new RefreshableMockEndpointProvider(endpoints);105const serviceCollection = store.add(createExtensionUnitTestingServices());106serviceCollection.set(IEndpointProvider, endpointProvider);107const instantiationService = serviceCollection.createTestingAccessor().get(IInstantiationService);108const service = store.add(instantiationService.createInstance(ClaudeCodeModels));109return { service, provider: endpointProvider };110}111112describe('resolveEndpoint', () => {113it('resolves by exact model match', async () => {114const { service } = createServiceWithRefreshableEndpoints([115createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),116createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),117]);118119const endpoint = await service.resolveEndpoint('claude-opus-4.5', undefined);120expect(endpoint?.model).toBe('claude-opus-4.5');121});122123it('resolves by family match', async () => {124const { service } = createServiceWithRefreshableEndpoints([125createMockEndpoint({ model: 'claude-sonnet-4-model', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),126]);127128const endpoint = await service.resolveEndpoint('claude-sonnet-4', undefined);129expect(endpoint?.model).toBe('claude-sonnet-4-model');130});131132it('maps SDK model ID format to endpoint format', async () => {133const { service } = createServiceWithRefreshableEndpoints([134createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),135]);136137// SDK format uses hyphens; endpoint format uses dots138const endpoint = await service.resolveEndpoint('claude-opus-4-5', undefined);139expect(endpoint?.model).toBe('claude-opus-4.5');140});141142it('falls back to fallbackModelId when requested model does not match', async () => {143const { service } = createServiceWithRefreshableEndpoints([144createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),145]);146147const fallback = tryParseClaudeModelId('claude-sonnet-4');148const endpoint = await service.resolveEndpoint('unknown-model', fallback);149expect(endpoint?.model).toBe('claude-sonnet-4');150});151152it('falls back to newest Sonnet when no exact or fallback match', async () => {153const { service } = createServiceWithRefreshableEndpoints([154createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),155createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),156createMockEndpoint({ model: 'claude-haiku-3.5', name: 'Claude Haiku 3.5', family: 'claude-haiku-3.5' }),157]);158159const endpoint = await service.resolveEndpoint('claude-nonexistent-99', undefined);160expect(endpoint?.model).toBe('claude-sonnet-4');161});162163it('falls back to newest Haiku when no Sonnet available', async () => {164const { service } = createServiceWithRefreshableEndpoints([165createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),166createMockEndpoint({ model: 'claude-haiku-3.5', name: 'Claude Haiku 3.5', family: 'claude-haiku-3.5' }),167]);168169const endpoint = await service.resolveEndpoint('claude-nonexistent-99', undefined);170expect(endpoint?.model).toBe('claude-haiku-3.5');171});172173it('falls back to any Claude model when no Sonnet or Haiku available', async () => {174const { service } = createServiceWithRefreshableEndpoints([175createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),176]);177178const endpoint = await service.resolveEndpoint('claude-nonexistent-99', undefined);179expect(endpoint?.model).toBe('claude-opus-4.5');180});181182it('falls back to Sonnet when no model is requested', async () => {183const { service } = createServiceWithRefreshableEndpoints([184createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),185createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),186]);187188const endpoint = await service.resolveEndpoint(undefined, undefined);189expect(endpoint?.model).toBe('claude-sonnet-4');190});191192it('does not fall back to non-Anthropic models', async () => {193const { service } = createServiceWithRefreshableEndpoints([194createMockEndpoint({ model: 'gpt-4o', name: 'GPT-4o', family: 'gpt-4', modelProvider: 'Azure OpenAI' }),195]);196197const endpoint = await service.resolveEndpoint('unknown-model', undefined);198expect(endpoint).toBeUndefined();199});200201it('returns undefined when no endpoints are available', async () => {202const { service } = createServiceWithRefreshableEndpoints([]);203204const endpoint = await service.resolveEndpoint('claude-sonnet-4', undefined);205expect(endpoint).toBeUndefined();206});207});208209describe('registerLanguageModelChatProvider', () => {210function createMockLm(): { lm: typeof vscode['lm']; getCapturedProvider: () => vscode.LanguageModelChatProvider | undefined } {211let capturedProvider: vscode.LanguageModelChatProvider | undefined;212const lm = {213registerLanguageModelChatProvider(_id: string, provider: vscode.LanguageModelChatProvider) {214capturedProvider = provider;215return { dispose: () => { } };216},217} as unknown as typeof vscode['lm'];218return { lm, getCapturedProvider: () => capturedProvider };219}220221async function getProviderInfo(service: ClaudeCodeModels, lm: typeof vscode['lm'], getCapturedProvider: () => vscode.LanguageModelChatProvider | undefined): Promise<vscode.LanguageModelChatInformation[]> {222service.registerLanguageModelChatProvider(lm);223const provider = getCapturedProvider()!;224const info = await provider.provideLanguageModelChatInformation!({} as any, {} as any);225return info ?? [];226}227228it('registers provider and surfaces endpoints as LanguageModelChatInformation', async () => {229const { service } = createServiceWithRefreshableEndpoints([230createMockEndpoint({ model: 'claude-sonnet-4-model', name: 'Claude Sonnet 4', family: 'claude-sonnet-4', multiplier: 1 }),231createMockEndpoint({ model: 'claude-opus-4.5-model', name: 'Claude Opus 4.5', family: 'claude-opus-4.5', multiplier: 5 }),232]);233const { lm, getCapturedProvider } = createMockLm();234235const info = await getProviderInfo(service, lm, getCapturedProvider);236expect(info).toHaveLength(2);237238const sonnet = info.find(i => i.id === 'claude-sonnet-4-model')!;239expect(sonnet.name).toBe('Claude Sonnet 4');240expect(sonnet.family).toBe('claude-sonnet-4');241expect(sonnet.pricing).toBe('1x');242expect(sonnet.targetChatSessionType).toBe('claude-code');243expect(sonnet.isUserSelectable).toBe(true);244245const opus = info.find(i => i.id === 'claude-opus-4.5-model')!;246expect(opus.pricing).toBe('5x');247});248249it('returns undefined multiplier string when endpoint has no multiplier', async () => {250const { service } = createServiceWithRefreshableEndpoints([251createMockEndpoint({ model: 'claude-sonnet-4-model', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),252]);253const { lm, getCapturedProvider } = createMockLm();254255const info = await getProviderInfo(service, lm, getCapturedProvider);256expect(info[0].pricing).toBeUndefined();257});258259it('returns empty array when no endpoints are available', async () => {260const { service } = createServiceWithRefreshableEndpoints([]);261const { lm, getCapturedProvider } = createMockLm();262263const info = await getProviderInfo(service, lm, getCapturedProvider);264expect(info).toHaveLength(0);265});266267it('maps endpoint properties to LanguageModelChatInformation fields', async () => {268const endpoint = createMockEndpoint({ model: 'claude-sonnet-4-model', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' });269const { service } = createServiceWithRefreshableEndpoints([endpoint]);270const { lm, getCapturedProvider } = createMockLm();271272const info = await getProviderInfo(service, lm, getCapturedProvider);273expect(info[0].maxInputTokens).toBe(endpoint.modelMaxPromptTokens);274expect(info[0].maxOutputTokens).toBe(endpoint.maxOutputTokens);275expect(info[0].version).toBe(endpoint.version);276});277it('includes configurationSchema when endpoint supports multiple reasoning effort levels', async () => {278const { service } = createServiceWithRefreshableEndpoints([279createMockEndpoint({280model: 'claude-sonnet-4-model',281name: 'Claude Sonnet 4',282family: 'claude-sonnet-4',283supportsReasoningEffort: ['low', 'medium', 'high'],284}),285]);286const { lm, getCapturedProvider } = createMockLm();287288const info = await getProviderInfo(service, lm, getCapturedProvider);289expect(info[0].configurationSchema).toBeDefined();290const schema = info[0].configurationSchema!;291expect(schema.properties?.['reasoningEffort']).toBeDefined();292expect(schema.properties!['reasoningEffort'].enum).toEqual(['low', 'medium', 'high']);293expect(schema.properties!['reasoningEffort'].default).toBe('high');294});295296it('omits configurationSchema when endpoint has no reasoning effort support', async () => {297const { service } = createServiceWithRefreshableEndpoints([298createMockEndpoint({299model: 'claude-sonnet-4-model',300name: 'Claude Sonnet 4',301family: 'claude-sonnet-4',302}),303]);304const { lm, getCapturedProvider } = createMockLm();305306const info = await getProviderInfo(service, lm, getCapturedProvider);307expect(info[0].configurationSchema).toBeUndefined();308});309310it('includes configurationSchema when endpoint has only one reasoning effort level', async () => {311const { service } = createServiceWithRefreshableEndpoints([312createMockEndpoint({313model: 'claude-sonnet-4-model',314name: 'Claude Sonnet 4',315family: 'claude-sonnet-4',316supportsReasoningEffort: ['high'],317}),318]);319const { lm, getCapturedProvider } = createMockLm();320321const info = await getProviderInfo(service, lm, getCapturedProvider);322expect(info[0].configurationSchema).toBeDefined();323const schema = info[0].configurationSchema!;324expect(schema.properties?.['reasoningEffort'].enum).toEqual(['high']);325expect(schema.properties!['reasoningEffort'].default).toBe('high');326});327});328329describe('resolveEndpoint with ParsedClaudeModelId', () => {330it('resolves endpoint when given a ParsedClaudeModelId', async () => {331const { service } = createServiceWithRefreshableEndpoints([332createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),333]);334335const parsedId = tryParseClaudeModelId('claude-sonnet-4')!;336const endpoint = await service.resolveEndpoint(parsedId, undefined);337expect(endpoint?.model).toBe('claude-sonnet-4');338});339340it('maps ParsedClaudeModelId to endpoint format', async () => {341const { service } = createServiceWithRefreshableEndpoints([342createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),343]);344345const parsedId = tryParseClaudeModelId('claude-opus-4-5')!;346const endpoint = await service.resolveEndpoint(parsedId, undefined);347expect(endpoint?.model).toBe('claude-opus-4.5');348});349});350351describe('resolveReasoningEffort', () => {352it('returns requested effort level when endpoint supports it', async () => {353const { service } = createServiceWithRefreshableEndpoints([354createMockEndpoint({355model: 'claude-sonnet-4',356name: 'Claude Sonnet 4',357family: 'claude-sonnet-4',358supportsReasoningEffort: ['low', 'medium', 'high'],359}),360]);361362const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');363expect(result).toBe('high');364});365366it('returns undefined when endpoint does not support reasoning effort', async () => {367const { service } = createServiceWithRefreshableEndpoints([368createMockEndpoint({369model: 'claude-sonnet-4',370name: 'Claude Sonnet 4',371family: 'claude-sonnet-4',372}),373]);374375const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');376expect(result).toBeUndefined();377});378379it('returns undefined when endpoint has empty reasoning effort array', async () => {380const { service } = createServiceWithRefreshableEndpoints([381createMockEndpoint({382model: 'claude-sonnet-4',383name: 'Claude Sonnet 4',384family: 'claude-sonnet-4',385supportsReasoningEffort: [],386}),387]);388389const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');390expect(result).toBeUndefined();391});392393it('returns the single supported level when endpoint supports exactly one', async () => {394const { service } = createServiceWithRefreshableEndpoints([395createMockEndpoint({396model: 'claude-sonnet-4',397name: 'Claude Sonnet 4',398family: 'claude-sonnet-4',399supportsReasoningEffort: ['high'],400}),401]);402403const result = await service.resolveReasoningEffort('claude-sonnet-4', undefined);404expect(result).toBe('high');405});406407it('returns undefined when requested effort is not supported by the endpoint', async () => {408const { service } = createServiceWithRefreshableEndpoints([409createMockEndpoint({410model: 'claude-sonnet-4',411name: 'Claude Sonnet 4',412family: 'claude-sonnet-4',413supportsReasoningEffort: ['low', 'medium'],414}),415]);416417const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');418expect(result).toBeUndefined();419});420421it('returns undefined when requested effort is not a valid EffortLevel', async () => {422const { service } = createServiceWithRefreshableEndpoints([423createMockEndpoint({424model: 'claude-sonnet-4',425name: 'Claude Sonnet 4',426family: 'claude-sonnet-4',427supportsReasoningEffort: ['low', 'medium', 'high'],428}),429]);430431const result = await service.resolveReasoningEffort('claude-sonnet-4', 'invalid-level');432expect(result).toBeUndefined();433});434435it('returns undefined when no endpoints are available', async () => {436const { service } = createServiceWithRefreshableEndpoints([]);437438const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');439expect(result).toBeUndefined();440});441442it('accepts a ParsedClaudeModelId for requestedModel', async () => {443const { service } = createServiceWithRefreshableEndpoints([444createMockEndpoint({445model: 'claude-sonnet-4',446name: 'Claude Sonnet 4',447family: 'claude-sonnet-4',448supportsReasoningEffort: ['low', 'medium', 'high'],449}),450]);451452const parsedId = tryParseClaudeModelId('claude-sonnet-4')!;453const result = await service.resolveReasoningEffort(parsedId, 'medium');454expect(result).toBe('medium');455});456});457458describe('cache invalidation on onDidModelsRefresh', () => {459it('returns updated endpoints after refresh', async () => {460const { service, provider } = createServiceWithRefreshableEndpoints([461createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),462]);463464// Initial fetch465const before = await service.resolveEndpoint('claude-sonnet-4', undefined);466expect(before?.model).toBe('claude-sonnet-4');467468// Update endpoints and fire refresh469provider.setEndpoints([470createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),471createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),472]);473provider.fireRefresh();474475// After refresh, new endpoint should be resolvable476const after = await service.resolveEndpoint('claude-opus-4.5', undefined);477expect(after?.model).toBe('claude-opus-4.5');478});479480it('returns cached endpoints when no refresh has occurred', async () => {481let fetchCount = 0;482const endpointProvider = new RefreshableMockEndpointProvider([483createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),484]);485const originalGetAll = endpointProvider.getAllChatEndpoints.bind(endpointProvider);486endpointProvider.getAllChatEndpoints = async () => {487fetchCount++;488return originalGetAll();489};490491const serviceCollection = store.add(createExtensionUnitTestingServices());492serviceCollection.set(IEndpointProvider, endpointProvider);493const instantiationService = serviceCollection.createTestingAccessor().get(IInstantiationService);494const service = store.add(instantiationService.createInstance(ClaudeCodeModels));495496await service.resolveEndpoint(undefined, undefined);497await service.resolveEndpoint(undefined, undefined);498499// Should only have fetched once due to caching500expect(fetchCount).toBe(1);501});502});503});504505describe('isEffortLevel', () => {506it('returns true for valid effort levels', () => {507expect(isEffortLevel('low')).toBe(true);508expect(isEffortLevel('medium')).toBe(true);509expect(isEffortLevel('high')).toBe(true);510});511512it('returns false for invalid effort levels', () => {513expect(isEffortLevel('invalid')).toBe(false);514expect(isEffortLevel('')).toBe(false);515expect(isEffortLevel('HIGH')).toBe(false);516expect(isEffortLevel('Low')).toBe(false);517});518});519520521