Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { afterEach, describe, expect, it } from 'vitest';
7
import type * as vscode from 'vscode';
8
import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider';
9
import { IChatEndpoint } from '../../../../../platform/networking/common/networking';
10
import { Emitter } from '../../../../../util/vs/base/common/event';
11
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
12
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
13
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
14
import { ClaudeCodeModels, isEffortLevel } from '../claudeCodeModels';
15
import { tryParseClaudeModelId } from '../claudeModelId';
16
17
/**
18
* Creates a minimal mock IChatEndpoint with required properties for testing
19
*/
20
function createMockEndpoint(overrides: {
21
model: string;
22
name: string;
23
family: string;
24
showInModelPicker?: boolean;
25
multiplier?: number;
26
apiType?: string;
27
modelProvider?: string;
28
supportsReasoningEffort?: string[];
29
}): IChatEndpoint {
30
const isAnthropic = overrides.modelProvider === undefined || overrides.modelProvider === 'Anthropic';
31
return {
32
model: overrides.model,
33
name: overrides.name,
34
family: overrides.family,
35
version: '1.0',
36
showInModelPicker: overrides.showInModelPicker ?? true,
37
multiplier: overrides.multiplier,
38
modelProvider: overrides.modelProvider ?? 'Anthropic',
39
apiType: overrides.apiType ?? (isAnthropic ? 'messages' : 'chatCompletions'),
40
// Required properties with sensible defaults
41
maxOutputTokens: 4096,
42
supportsToolCalls: true,
43
supportsVision: false,
44
supportsPrediction: false,
45
supportsReasoningEffort: overrides.supportsReasoningEffort,
46
isDefault: false,
47
isFallback: false,
48
policy: 'enabled',
49
urlOrRequestMetadata: 'mock://endpoint',
50
modelMaxPromptTokens: 128000,
51
tokenizer: 'cl100k_base',
52
acquireTokenizer: () => ({ encode: () => [], free: () => { } }) as any,
53
processResponseFromChatEndpoint: () => Promise.resolve({} as any),
54
acceptChatPolicy: () => Promise.resolve(true),
55
fetchChatResponse: () => Promise.resolve({} as any),
56
} as unknown as IChatEndpoint;
57
}
58
59
/**
60
* Mock endpoint provider that supports firing onDidModelsRefresh and updating endpoints.
61
*/
62
class RefreshableMockEndpointProvider implements IEndpointProvider {
63
declare readonly _serviceBrand: undefined;
64
private readonly _onDidModelsRefresh = new Emitter<void>();
65
readonly onDidModelsRefresh = this._onDidModelsRefresh.event;
66
private _endpoints: IChatEndpoint[];
67
68
constructor(endpoints: IChatEndpoint[]) {
69
this._endpoints = endpoints;
70
}
71
72
setEndpoints(endpoints: IChatEndpoint[]): void {
73
this._endpoints = endpoints;
74
}
75
76
fireRefresh(): void {
77
this._onDidModelsRefresh.fire();
78
}
79
80
async getAllChatEndpoints(): Promise<IChatEndpoint[]> {
81
return this._endpoints;
82
}
83
84
getChatEndpoint(): Promise<IChatEndpoint> {
85
throw new Error('Not implemented');
86
}
87
getEmbeddingsEndpoint(): Promise<any> {
88
throw new Error('Not implemented');
89
}
90
getAllCompletionModels(): Promise<any[]> {
91
throw new Error('Not implemented');
92
}
93
}
94
95
describe('ClaudeCodeModels', () => {
96
const store = new DisposableStore();
97
98
afterEach(() => {
99
store.clear();
100
});
101
102
function createServiceWithRefreshableEndpoints(
103
endpoints: IChatEndpoint[],
104
): { service: ClaudeCodeModels; provider: RefreshableMockEndpointProvider } {
105
const endpointProvider = new RefreshableMockEndpointProvider(endpoints);
106
const serviceCollection = store.add(createExtensionUnitTestingServices());
107
serviceCollection.set(IEndpointProvider, endpointProvider);
108
const instantiationService = serviceCollection.createTestingAccessor().get(IInstantiationService);
109
const service = store.add(instantiationService.createInstance(ClaudeCodeModels));
110
return { service, provider: endpointProvider };
111
}
112
113
describe('resolveEndpoint', () => {
114
it('resolves by exact model match', async () => {
115
const { service } = createServiceWithRefreshableEndpoints([
116
createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
117
createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),
118
]);
119
120
const endpoint = await service.resolveEndpoint('claude-opus-4.5', undefined);
121
expect(endpoint?.model).toBe('claude-opus-4.5');
122
});
123
124
it('resolves by family match', async () => {
125
const { service } = createServiceWithRefreshableEndpoints([
126
createMockEndpoint({ model: 'claude-sonnet-4-model', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
127
]);
128
129
const endpoint = await service.resolveEndpoint('claude-sonnet-4', undefined);
130
expect(endpoint?.model).toBe('claude-sonnet-4-model');
131
});
132
133
it('maps SDK model ID format to endpoint format', async () => {
134
const { service } = createServiceWithRefreshableEndpoints([
135
createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),
136
]);
137
138
// SDK format uses hyphens; endpoint format uses dots
139
const endpoint = await service.resolveEndpoint('claude-opus-4-5', undefined);
140
expect(endpoint?.model).toBe('claude-opus-4.5');
141
});
142
143
it('falls back to fallbackModelId when requested model does not match', async () => {
144
const { service } = createServiceWithRefreshableEndpoints([
145
createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
146
]);
147
148
const fallback = tryParseClaudeModelId('claude-sonnet-4');
149
const endpoint = await service.resolveEndpoint('unknown-model', fallback);
150
expect(endpoint?.model).toBe('claude-sonnet-4');
151
});
152
153
it('falls back to newest Sonnet when no exact or fallback match', async () => {
154
const { service } = createServiceWithRefreshableEndpoints([
155
createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),
156
createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
157
createMockEndpoint({ model: 'claude-haiku-3.5', name: 'Claude Haiku 3.5', family: 'claude-haiku-3.5' }),
158
]);
159
160
const endpoint = await service.resolveEndpoint('claude-nonexistent-99', undefined);
161
expect(endpoint?.model).toBe('claude-sonnet-4');
162
});
163
164
it('falls back to newest Haiku when no Sonnet available', async () => {
165
const { service } = createServiceWithRefreshableEndpoints([
166
createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),
167
createMockEndpoint({ model: 'claude-haiku-3.5', name: 'Claude Haiku 3.5', family: 'claude-haiku-3.5' }),
168
]);
169
170
const endpoint = await service.resolveEndpoint('claude-nonexistent-99', undefined);
171
expect(endpoint?.model).toBe('claude-haiku-3.5');
172
});
173
174
it('falls back to any Claude model when no Sonnet or Haiku available', async () => {
175
const { service } = createServiceWithRefreshableEndpoints([
176
createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),
177
]);
178
179
const endpoint = await service.resolveEndpoint('claude-nonexistent-99', undefined);
180
expect(endpoint?.model).toBe('claude-opus-4.5');
181
});
182
183
it('falls back to Sonnet when no model is requested', async () => {
184
const { service } = createServiceWithRefreshableEndpoints([
185
createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),
186
createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
187
]);
188
189
const endpoint = await service.resolveEndpoint(undefined, undefined);
190
expect(endpoint?.model).toBe('claude-sonnet-4');
191
});
192
193
it('does not fall back to non-Anthropic models', async () => {
194
const { service } = createServiceWithRefreshableEndpoints([
195
createMockEndpoint({ model: 'gpt-4o', name: 'GPT-4o', family: 'gpt-4', modelProvider: 'Azure OpenAI' }),
196
]);
197
198
const endpoint = await service.resolveEndpoint('unknown-model', undefined);
199
expect(endpoint).toBeUndefined();
200
});
201
202
it('returns undefined when no endpoints are available', async () => {
203
const { service } = createServiceWithRefreshableEndpoints([]);
204
205
const endpoint = await service.resolveEndpoint('claude-sonnet-4', undefined);
206
expect(endpoint).toBeUndefined();
207
});
208
});
209
210
describe('registerLanguageModelChatProvider', () => {
211
function createMockLm(): { lm: typeof vscode['lm']; getCapturedProvider: () => vscode.LanguageModelChatProvider | undefined } {
212
let capturedProvider: vscode.LanguageModelChatProvider | undefined;
213
const lm = {
214
registerLanguageModelChatProvider(_id: string, provider: vscode.LanguageModelChatProvider) {
215
capturedProvider = provider;
216
return { dispose: () => { } };
217
},
218
} as unknown as typeof vscode['lm'];
219
return { lm, getCapturedProvider: () => capturedProvider };
220
}
221
222
async function getProviderInfo(service: ClaudeCodeModels, lm: typeof vscode['lm'], getCapturedProvider: () => vscode.LanguageModelChatProvider | undefined): Promise<vscode.LanguageModelChatInformation[]> {
223
service.registerLanguageModelChatProvider(lm);
224
const provider = getCapturedProvider()!;
225
const info = await provider.provideLanguageModelChatInformation!({} as any, {} as any);
226
return info ?? [];
227
}
228
229
it('registers provider and surfaces endpoints as LanguageModelChatInformation', async () => {
230
const { service } = createServiceWithRefreshableEndpoints([
231
createMockEndpoint({ model: 'claude-sonnet-4-model', name: 'Claude Sonnet 4', family: 'claude-sonnet-4', multiplier: 1 }),
232
createMockEndpoint({ model: 'claude-opus-4.5-model', name: 'Claude Opus 4.5', family: 'claude-opus-4.5', multiplier: 5 }),
233
]);
234
const { lm, getCapturedProvider } = createMockLm();
235
236
const info = await getProviderInfo(service, lm, getCapturedProvider);
237
expect(info).toHaveLength(2);
238
239
const sonnet = info.find(i => i.id === 'claude-sonnet-4-model')!;
240
expect(sonnet.name).toBe('Claude Sonnet 4');
241
expect(sonnet.family).toBe('claude-sonnet-4');
242
expect(sonnet.pricing).toBe('1x');
243
expect(sonnet.targetChatSessionType).toBe('claude-code');
244
expect(sonnet.isUserSelectable).toBe(true);
245
246
const opus = info.find(i => i.id === 'claude-opus-4.5-model')!;
247
expect(opus.pricing).toBe('5x');
248
});
249
250
it('returns undefined multiplier string when endpoint has no multiplier', async () => {
251
const { service } = createServiceWithRefreshableEndpoints([
252
createMockEndpoint({ model: 'claude-sonnet-4-model', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
253
]);
254
const { lm, getCapturedProvider } = createMockLm();
255
256
const info = await getProviderInfo(service, lm, getCapturedProvider);
257
expect(info[0].pricing).toBeUndefined();
258
});
259
260
it('returns empty array when no endpoints are available', async () => {
261
const { service } = createServiceWithRefreshableEndpoints([]);
262
const { lm, getCapturedProvider } = createMockLm();
263
264
const info = await getProviderInfo(service, lm, getCapturedProvider);
265
expect(info).toHaveLength(0);
266
});
267
268
it('maps endpoint properties to LanguageModelChatInformation fields', async () => {
269
const endpoint = createMockEndpoint({ model: 'claude-sonnet-4-model', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' });
270
const { service } = createServiceWithRefreshableEndpoints([endpoint]);
271
const { lm, getCapturedProvider } = createMockLm();
272
273
const info = await getProviderInfo(service, lm, getCapturedProvider);
274
expect(info[0].maxInputTokens).toBe(endpoint.modelMaxPromptTokens);
275
expect(info[0].maxOutputTokens).toBe(endpoint.maxOutputTokens);
276
expect(info[0].version).toBe(endpoint.version);
277
});
278
it('includes configurationSchema when endpoint supports multiple reasoning effort levels', async () => {
279
const { service } = createServiceWithRefreshableEndpoints([
280
createMockEndpoint({
281
model: 'claude-sonnet-4-model',
282
name: 'Claude Sonnet 4',
283
family: 'claude-sonnet-4',
284
supportsReasoningEffort: ['low', 'medium', 'high'],
285
}),
286
]);
287
const { lm, getCapturedProvider } = createMockLm();
288
289
const info = await getProviderInfo(service, lm, getCapturedProvider);
290
expect(info[0].configurationSchema).toBeDefined();
291
const schema = info[0].configurationSchema!;
292
expect(schema.properties?.['reasoningEffort']).toBeDefined();
293
expect(schema.properties!['reasoningEffort'].enum).toEqual(['low', 'medium', 'high']);
294
expect(schema.properties!['reasoningEffort'].default).toBe('high');
295
});
296
297
it('omits configurationSchema when endpoint has no reasoning effort support', async () => {
298
const { service } = createServiceWithRefreshableEndpoints([
299
createMockEndpoint({
300
model: 'claude-sonnet-4-model',
301
name: 'Claude Sonnet 4',
302
family: 'claude-sonnet-4',
303
}),
304
]);
305
const { lm, getCapturedProvider } = createMockLm();
306
307
const info = await getProviderInfo(service, lm, getCapturedProvider);
308
expect(info[0].configurationSchema).toBeUndefined();
309
});
310
311
it('includes configurationSchema when endpoint has only one reasoning effort level', async () => {
312
const { service } = createServiceWithRefreshableEndpoints([
313
createMockEndpoint({
314
model: 'claude-sonnet-4-model',
315
name: 'Claude Sonnet 4',
316
family: 'claude-sonnet-4',
317
supportsReasoningEffort: ['high'],
318
}),
319
]);
320
const { lm, getCapturedProvider } = createMockLm();
321
322
const info = await getProviderInfo(service, lm, getCapturedProvider);
323
expect(info[0].configurationSchema).toBeDefined();
324
const schema = info[0].configurationSchema!;
325
expect(schema.properties?.['reasoningEffort'].enum).toEqual(['high']);
326
expect(schema.properties!['reasoningEffort'].default).toBe('high');
327
});
328
});
329
330
describe('resolveEndpoint with ParsedClaudeModelId', () => {
331
it('resolves endpoint when given a ParsedClaudeModelId', async () => {
332
const { service } = createServiceWithRefreshableEndpoints([
333
createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
334
]);
335
336
const parsedId = tryParseClaudeModelId('claude-sonnet-4')!;
337
const endpoint = await service.resolveEndpoint(parsedId, undefined);
338
expect(endpoint?.model).toBe('claude-sonnet-4');
339
});
340
341
it('maps ParsedClaudeModelId to endpoint format', async () => {
342
const { service } = createServiceWithRefreshableEndpoints([
343
createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),
344
]);
345
346
const parsedId = tryParseClaudeModelId('claude-opus-4-5')!;
347
const endpoint = await service.resolveEndpoint(parsedId, undefined);
348
expect(endpoint?.model).toBe('claude-opus-4.5');
349
});
350
});
351
352
describe('resolveReasoningEffort', () => {
353
it('returns requested effort level when endpoint supports it', async () => {
354
const { service } = createServiceWithRefreshableEndpoints([
355
createMockEndpoint({
356
model: 'claude-sonnet-4',
357
name: 'Claude Sonnet 4',
358
family: 'claude-sonnet-4',
359
supportsReasoningEffort: ['low', 'medium', 'high'],
360
}),
361
]);
362
363
const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');
364
expect(result).toBe('high');
365
});
366
367
it('returns undefined when endpoint does not support reasoning effort', async () => {
368
const { service } = createServiceWithRefreshableEndpoints([
369
createMockEndpoint({
370
model: 'claude-sonnet-4',
371
name: 'Claude Sonnet 4',
372
family: 'claude-sonnet-4',
373
}),
374
]);
375
376
const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');
377
expect(result).toBeUndefined();
378
});
379
380
it('returns undefined when endpoint has empty reasoning effort array', async () => {
381
const { service } = createServiceWithRefreshableEndpoints([
382
createMockEndpoint({
383
model: 'claude-sonnet-4',
384
name: 'Claude Sonnet 4',
385
family: 'claude-sonnet-4',
386
supportsReasoningEffort: [],
387
}),
388
]);
389
390
const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');
391
expect(result).toBeUndefined();
392
});
393
394
it('returns the single supported level when endpoint supports exactly one', async () => {
395
const { service } = createServiceWithRefreshableEndpoints([
396
createMockEndpoint({
397
model: 'claude-sonnet-4',
398
name: 'Claude Sonnet 4',
399
family: 'claude-sonnet-4',
400
supportsReasoningEffort: ['high'],
401
}),
402
]);
403
404
const result = await service.resolveReasoningEffort('claude-sonnet-4', undefined);
405
expect(result).toBe('high');
406
});
407
408
it('returns undefined when requested effort is not supported by the endpoint', async () => {
409
const { service } = createServiceWithRefreshableEndpoints([
410
createMockEndpoint({
411
model: 'claude-sonnet-4',
412
name: 'Claude Sonnet 4',
413
family: 'claude-sonnet-4',
414
supportsReasoningEffort: ['low', 'medium'],
415
}),
416
]);
417
418
const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');
419
expect(result).toBeUndefined();
420
});
421
422
it('returns undefined when requested effort is not a valid EffortLevel', async () => {
423
const { service } = createServiceWithRefreshableEndpoints([
424
createMockEndpoint({
425
model: 'claude-sonnet-4',
426
name: 'Claude Sonnet 4',
427
family: 'claude-sonnet-4',
428
supportsReasoningEffort: ['low', 'medium', 'high'],
429
}),
430
]);
431
432
const result = await service.resolveReasoningEffort('claude-sonnet-4', 'invalid-level');
433
expect(result).toBeUndefined();
434
});
435
436
it('returns undefined when no endpoints are available', async () => {
437
const { service } = createServiceWithRefreshableEndpoints([]);
438
439
const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high');
440
expect(result).toBeUndefined();
441
});
442
443
it('accepts a ParsedClaudeModelId for requestedModel', async () => {
444
const { service } = createServiceWithRefreshableEndpoints([
445
createMockEndpoint({
446
model: 'claude-sonnet-4',
447
name: 'Claude Sonnet 4',
448
family: 'claude-sonnet-4',
449
supportsReasoningEffort: ['low', 'medium', 'high'],
450
}),
451
]);
452
453
const parsedId = tryParseClaudeModelId('claude-sonnet-4')!;
454
const result = await service.resolveReasoningEffort(parsedId, 'medium');
455
expect(result).toBe('medium');
456
});
457
});
458
459
describe('cache invalidation on onDidModelsRefresh', () => {
460
it('returns updated endpoints after refresh', async () => {
461
const { service, provider } = createServiceWithRefreshableEndpoints([
462
createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
463
]);
464
465
// Initial fetch
466
const before = await service.resolveEndpoint('claude-sonnet-4', undefined);
467
expect(before?.model).toBe('claude-sonnet-4');
468
469
// Update endpoints and fire refresh
470
provider.setEndpoints([
471
createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
472
createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }),
473
]);
474
provider.fireRefresh();
475
476
// After refresh, new endpoint should be resolvable
477
const after = await service.resolveEndpoint('claude-opus-4.5', undefined);
478
expect(after?.model).toBe('claude-opus-4.5');
479
});
480
481
it('returns cached endpoints when no refresh has occurred', async () => {
482
let fetchCount = 0;
483
const endpointProvider = new RefreshableMockEndpointProvider([
484
createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }),
485
]);
486
const originalGetAll = endpointProvider.getAllChatEndpoints.bind(endpointProvider);
487
endpointProvider.getAllChatEndpoints = async () => {
488
fetchCount++;
489
return originalGetAll();
490
};
491
492
const serviceCollection = store.add(createExtensionUnitTestingServices());
493
serviceCollection.set(IEndpointProvider, endpointProvider);
494
const instantiationService = serviceCollection.createTestingAccessor().get(IInstantiationService);
495
const service = store.add(instantiationService.createInstance(ClaudeCodeModels));
496
497
await service.resolveEndpoint(undefined, undefined);
498
await service.resolveEndpoint(undefined, undefined);
499
500
// Should only have fetched once due to caching
501
expect(fetchCount).toBe(1);
502
});
503
});
504
});
505
506
describe('isEffortLevel', () => {
507
it('returns true for valid effort levels', () => {
508
expect(isEffortLevel('low')).toBe(true);
509
expect(isEffortLevel('medium')).toBe(true);
510
expect(isEffortLevel('high')).toBe(true);
511
});
512
513
it('returns false for invalid effort levels', () => {
514
expect(isEffortLevel('invalid')).toBe(false);
515
expect(isEffortLevel('')).toBe(false);
516
expect(isEffortLevel('HIGH')).toBe(false);
517
expect(isEffortLevel('Low')).toBe(false);
518
});
519
});
520
521