Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts
13405 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 { Raw } from '@vscode/prompt-tsx';
7
import { beforeEach, describe, expect, it } from 'vitest';
8
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
9
import { IAuthenticationService } from '../../../authentication/common/authentication';
10
import { IChatMLFetcher } from '../../../chat/common/chatMLFetcher';
11
12
import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService';
13
import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService';
14
import { ICAPIClientService } from '../../../endpoint/common/capiClient';
15
import { IDomainService } from '../../../endpoint/common/domainService';
16
import { IChatModelInformation, ModelSupportedEndpoint } from '../../../endpoint/common/endpointProvider';
17
import { IEnvService } from '../../../env/common/envService';
18
import { ILogService } from '../../../log/common/logService';
19
import { IFetcherService } from '../../../networking/common/fetcherService';
20
import { ICreateEndpointBodyOptions } from '../../../networking/common/networking';
21
import { IChatWebSocketManager } from '../../../networking/node/chatWebSocketManager';
22
import { NullExperimentationService } from '../../../telemetry/common/nullExperimentationService';
23
import { ITelemetryService } from '../../../telemetry/common/telemetry';
24
import { ITokenizerProvider } from '../../../tokenizer/node/tokenizer';
25
import { ChatEndpoint } from '../chatEndpoint';
26
import { CopilotChatEndpoint } from '../copilotChatEndpoint';
27
28
// Test fixtures for thinking content
29
const createThinkingMessage = (thinkingId: string, thinkingText: string): Raw.ChatMessage => ({
30
role: Raw.ChatRole.Assistant,
31
content: [
32
{
33
type: Raw.ChatCompletionContentPartKind.Opaque,
34
value: {
35
type: 'thinking',
36
thinking: {
37
id: thinkingId,
38
text: thinkingText
39
}
40
}
41
}
42
]
43
});
44
45
const createTestOptions = (messages: Raw.ChatMessage[]): ICreateEndpointBodyOptions => ({
46
debugName: 'test',
47
messages,
48
requestId: 'test-req-123',
49
postOptions: {},
50
finishedCb: undefined,
51
location: undefined as any
52
});
53
54
// Mock implementations
55
const createMockServices = () => ({
56
fetcherService: {} as IFetcherService,
57
domainService: {} as IDomainService,
58
capiClientService: {} as ICAPIClientService,
59
envService: {} as IEnvService,
60
telemetryService: {} as ITelemetryService,
61
authService: {} as IAuthenticationService,
62
chatMLFetcher: {} as IChatMLFetcher,
63
tokenizerProvider: {} as ITokenizerProvider,
64
instantiationService: {} as IInstantiationService,
65
configurationService: new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()),
66
expService: new NullExperimentationService(),
67
chatWebSocketService: {} as IChatWebSocketManager,
68
logService: {} as ILogService
69
});
70
71
72
73
const createNonAnthropicModelMetadata = (family: string): IChatModelInformation => ({
74
id: `${family}-test`,
75
vendor: `${family} Vendor`,
76
name: `${family} Test Model`,
77
version: '1.0',
78
model_picker_enabled: true,
79
is_chat_default: false,
80
is_chat_fallback: false,
81
capabilities: {
82
type: 'chat',
83
family: family,
84
tokenizer: 'o200k_base' as any,
85
supports: {
86
parallel_tool_calls: true,
87
streaming: true,
88
tool_calls: true,
89
vision: false,
90
prediction: false,
91
thinking: false
92
},
93
limits: {
94
max_prompt_tokens: 8192,
95
max_output_tokens: 4096,
96
max_context_window_tokens: 12288
97
}
98
}
99
});
100
101
describe('CopilotChatEndpoint - Reasoning Properties', () => {
102
let mockServices: ReturnType<typeof createMockServices>;
103
let modelMetadata: IChatModelInformation;
104
105
beforeEach(() => {
106
mockServices = createMockServices();
107
modelMetadata = {
108
id: 'copilot-base',
109
vendor: 'Copilot',
110
name: 'Copilot Base',
111
version: '1.0',
112
model_picker_enabled: true,
113
is_chat_default: true,
114
is_chat_fallback: false,
115
capabilities: {
116
type: 'chat',
117
family: 'copilot',
118
tokenizer: 'o200k_base' as any,
119
supports: {
120
parallel_tool_calls: true,
121
streaming: true,
122
tool_calls: true,
123
vision: false,
124
prediction: false,
125
thinking: true
126
},
127
limits: {
128
max_prompt_tokens: 8192,
129
max_output_tokens: 4096,
130
max_context_window_tokens: 12288
131
}
132
}
133
};
134
});
135
136
describe('CAPI reasoning properties', () => {
137
it('should set reasoning_opaque and reasoning_text properties when processing thinking content', () => {
138
const endpoint = new CopilotChatEndpoint(
139
modelMetadata,
140
mockServices.domainService,
141
mockServices.capiClientService,
142
mockServices.fetcherService,
143
mockServices.envService,
144
mockServices.telemetryService,
145
mockServices.authService,
146
mockServices.chatMLFetcher,
147
mockServices.tokenizerProvider,
148
mockServices.instantiationService,
149
mockServices.configurationService,
150
mockServices.expService,
151
mockServices.chatWebSocketService,
152
mockServices.logService
153
);
154
155
const thinkingMessage = createThinkingMessage('copilot-thinking-abc', 'copilot reasoning process');
156
const options = createTestOptions([thinkingMessage]);
157
158
const body = endpoint.createRequestBody(options);
159
160
expect(body.messages).toBeDefined();
161
const messages = body.messages as any[];
162
expect(messages).toHaveLength(1);
163
expect(messages[0].reasoning_opaque).toBe('copilot-thinking-abc');
164
expect(messages[0].reasoning_text).toBe('copilot reasoning process');
165
});
166
167
it('should handle multiple messages with thinking content', () => {
168
const endpoint = new CopilotChatEndpoint(
169
modelMetadata,
170
mockServices.domainService,
171
mockServices.capiClientService,
172
mockServices.fetcherService,
173
mockServices.envService,
174
mockServices.telemetryService,
175
mockServices.authService,
176
mockServices.chatMLFetcher,
177
mockServices.tokenizerProvider,
178
mockServices.instantiationService,
179
mockServices.configurationService,
180
mockServices.expService,
181
mockServices.chatWebSocketService,
182
mockServices.logService
183
);
184
185
const userMessage: Raw.ChatMessage = {
186
role: Raw.ChatRole.User,
187
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Help me with code' }]
188
};
189
const thinkingMessage = createThinkingMessage('copilot-reasoning-def', 'analyzing the code request');
190
const options = createTestOptions([userMessage, thinkingMessage]);
191
192
const body = endpoint.createRequestBody(options);
193
194
expect(body.messages).toBeDefined();
195
const messages = body.messages as any[];
196
expect(messages).toHaveLength(2);
197
198
// User message should not have reasoning properties
199
expect(messages[0].reasoning_opaque).toBeUndefined();
200
expect(messages[0].reasoning_text).toBeUndefined();
201
202
// Assistant message should have reasoning properties
203
expect(messages[1].reasoning_opaque).toBe('copilot-reasoning-def');
204
expect(messages[1].reasoning_text).toBe('analyzing the code request');
205
});
206
207
it('should handle messages without thinking content', () => {
208
const endpoint = new CopilotChatEndpoint(
209
modelMetadata,
210
mockServices.domainService,
211
mockServices.capiClientService,
212
mockServices.fetcherService,
213
mockServices.envService,
214
mockServices.telemetryService,
215
mockServices.authService,
216
mockServices.chatMLFetcher,
217
mockServices.tokenizerProvider,
218
mockServices.instantiationService,
219
mockServices.configurationService,
220
mockServices.expService,
221
mockServices.chatWebSocketService,
222
mockServices.logService
223
);
224
225
const regularMessage: Raw.ChatMessage = {
226
role: Raw.ChatRole.Assistant,
227
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Regular response' }]
228
};
229
const options = createTestOptions([regularMessage]);
230
231
const body = endpoint.createRequestBody(options);
232
233
expect(body.messages).toBeDefined();
234
const messages = body.messages as any[];
235
expect(messages).toHaveLength(1);
236
expect(messages[0].reasoning_opaque).toBeUndefined();
237
expect(messages[0].reasoning_text).toBeUndefined();
238
});
239
});
240
});
241
242
describe('ChatEndpoint - Image Count Validation', () => {
243
let mockServices: ReturnType<typeof createMockServices>;
244
245
beforeEach(() => {
246
mockServices = createMockServices();
247
});
248
249
const createImageMessage = (imageCount: number = 1): Raw.ChatMessage => ({
250
role: Raw.ChatRole.User,
251
content: [
252
{ type: Raw.ChatCompletionContentPartKind.Text, text: 'What is in this image?' },
253
...Array.from({ length: imageCount }, () => ({
254
type: Raw.ChatCompletionContentPartKind.Image as const,
255
imageUrl: { url: 'data:image/png;base64,test' }
256
}))
257
]
258
});
259
260
const createAssistantMessage = (): Raw.ChatMessage => ({
261
role: Raw.ChatRole.Assistant,
262
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'I see an image.' }]
263
});
264
265
const createGeminiModelMetadata = (maxPromptImages?: number): IChatModelInformation => {
266
const baseMetadata = createNonAnthropicModelMetadata('gemini-3');
267
return {
268
...baseMetadata,
269
capabilities: {
270
...baseMetadata.capabilities,
271
supports: {
272
...baseMetadata.capabilities.supports,
273
vision: true
274
},
275
limits: {
276
...baseMetadata.capabilities.limits,
277
...(maxPromptImages !== undefined ? { vision: { max_prompt_images: maxPromptImages } } : {})
278
}
279
}
280
};
281
};
282
283
const createAnthropicMessagesModelMetadata = (): IChatModelInformation => {
284
const baseMetadata = createNonAnthropicModelMetadata('claude-sonnet-4');
285
return {
286
...baseMetadata,
287
supported_endpoints: [ModelSupportedEndpoint.Messages],
288
capabilities: {
289
...baseMetadata.capabilities,
290
supports: {
291
...baseMetadata.capabilities.supports,
292
vision: true
293
}
294
}
295
};
296
};
297
298
const createEndpoint = (metadata: IChatModelInformation) =>
299
new ChatEndpoint(
300
metadata,
301
mockServices.domainService,
302
mockServices.chatMLFetcher,
303
mockServices.tokenizerProvider,
304
mockServices.instantiationService,
305
mockServices.configurationService,
306
mockServices.expService,
307
mockServices.chatWebSocketService,
308
mockServices.logService
309
);
310
311
const countImages = (messages: Raw.ChatMessage[]): number => {
312
let count = 0;
313
for (const msg of messages) {
314
if (Array.isArray(msg.content)) {
315
for (const part of msg.content) {
316
if (part.type === Raw.ChatCompletionContentPartKind.Image) {
317
count++;
318
}
319
}
320
}
321
}
322
return count;
323
};
324
325
// Exercises the private `validateAndFilterImages` method directly so we can
326
// assert on the filtered messages without being blocked by downstream mocks.
327
const filterImages = (endpoint: ChatEndpoint, messages: Raw.ChatMessage[], maxImages: number): Raw.ChatMessage[] => {
328
return (endpoint as unknown as { validateAndFilterImages(m: Raw.ChatMessage[], n: number): Raw.ChatMessage[] })
329
.validateAndFilterImages(messages, maxImages);
330
};
331
332
describe('Gemini image limits', () => {
333
it('should allow requests within image limit', () => {
334
const endpoint = createEndpoint(createGeminiModelMetadata(5));
335
const messages = [createImageMessage(), createImageMessage()];
336
const options = createTestOptions(messages);
337
expect(() => endpoint.createRequestBody(options)).not.toThrow();
338
// Input is within limit — messages should be returned untouched.
339
expect(filterImages(endpoint, messages, 5)).toBe(messages);
340
});
341
342
it('should silently filter history images when total exceeds limit', () => {
343
const endpoint = createEndpoint(createGeminiModelMetadata(3));
344
// 2 history user messages with 1 image each + current user message with 2 images = 4 total > 3 limit
345
const messages = [
346
createImageMessage(),
347
createAssistantMessage(),
348
createImageMessage(),
349
createAssistantMessage(),
350
createImageMessage(2),
351
];
352
expect(() => endpoint.createRequestBody(createTestOptions(messages))).not.toThrow();
353
const filtered = filterImages(endpoint, messages, 3);
354
// Total image parts in the filtered output must not exceed the limit.
355
expect(countImages(filtered)).toBeLessThanOrEqual(3);
356
// Current user message (last) must retain all 2 of its images.
357
expect(countImages([filtered[filtered.length - 1]])).toBe(2);
358
// Original messages must not be mutated.
359
expect(countImages(messages)).toBe(4);
360
});
361
});
362
363
describe('Anthropic Messages API image limits', () => {
364
it('should allow requests within image limit', () => {
365
const endpoint = createEndpoint(createAnthropicMessagesModelMetadata());
366
const messages = [createImageMessage(5)];
367
// Within limit — filter must not alter the messages.
368
expect(filterImages(endpoint, messages, 20)).toBe(messages);
369
});
370
371
it('should silently filter history images when total exceeds limit', () => {
372
const endpoint = createEndpoint(createAnthropicMessagesModelMetadata());
373
// Build history with 18 images + current message with 5 images = 23 total > 20 limit
374
const messages: Raw.ChatMessage[] = [];
375
for (let i = 0; i < 18; i++) {
376
messages.push(createImageMessage());
377
messages.push(createAssistantMessage());
378
}
379
messages.push(createImageMessage(5));
380
const filtered = filterImages(endpoint, messages, 20);
381
expect(countImages(filtered)).toBeLessThanOrEqual(20);
382
// Current user message must retain all 5 of its images.
383
expect(countImages([filtered[filtered.length - 1]])).toBe(5);
384
// Original messages must not be mutated.
385
expect(countImages(messages)).toBe(23);
386
});
387
});
388
389
describe('non-limited models', () => {
390
it('should not apply image limits to non-Anthropic non-Gemini models', () => {
391
const metadata = createNonAnthropicModelMetadata('gpt-4o');
392
const endpoint = createEndpoint(metadata);
393
// 25 images should not throw for a non-limited model
394
const options = createTestOptions([createImageMessage(25)]);
395
expect(() => endpoint.createRequestBody(options)).not.toThrow();
396
});
397
});
398
399
describe('edge cases', () => {
400
it('should filter tool-result images in history the same as user images', () => {
401
const endpoint = createEndpoint(createGeminiModelMetadata(2));
402
const toolResultImage: Raw.ChatMessage = {
403
role: Raw.ChatRole.Tool,
404
toolCallId: 'tool-1',
405
content: [
406
{ type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: 'https://example.com/tool.png' } }
407
]
408
};
409
// 2 tool-result images in history + 1 current user image = 3 total > 2 limit
410
const messages: Raw.ChatMessage[] = [
411
toolResultImage,
412
createAssistantMessage(),
413
toolResultImage,
414
createAssistantMessage(),
415
createImageMessage(1),
416
];
417
const filtered = filterImages(endpoint, messages, 2);
418
expect(countImages(filtered)).toBeLessThanOrEqual(2);
419
// Original messages must not be mutated.
420
expect(countImages(messages)).toBe(3);
421
});
422
423
it('should ignore an overly-restrictive server-provided maxPromptImages and use the hardcoded Gemini limit of 10', () => {
424
// Server reports max_prompt_images: 1 but the true Gemini limit is 10.
425
// 2 images in the current turn must not throw.
426
const endpoint = createEndpoint(createGeminiModelMetadata(1));
427
const options = createTestOptions([createImageMessage(2)]);
428
expect(() => endpoint.createRequestBody(options)).not.toThrow();
429
});
430
431
it('should throw using the hardcoded Gemini limit of 10 when the current turn exceeds it', () => {
432
const endpoint = createEndpoint(createGeminiModelMetadata(1));
433
const options = createTestOptions([createImageMessage(11)]);
434
expect(() => endpoint.createRequestBody(options)).toThrow(/maximum of 10 images/);
435
});
436
437
it('should throw using the hardcoded Anthropic Messages limit of 20 when the current turn exceeds it', () => {
438
const endpoint = createEndpoint(createAnthropicMessagesModelMetadata());
439
const options = createTestOptions([createImageMessage(21)]);
440
expect(() => endpoint.createRequestBody(options)).toThrow(/maximum of 20 images/);
441
});
442
443
it('should throw a clear error when the current turn alone exceeds the limit', () => {
444
const endpoint = createEndpoint(createGeminiModelMetadata(2));
445
// Current user message has 5 images, limit is 2. History has 1 image.
446
const messages = [
447
createImageMessage(),
448
createAssistantMessage(),
449
createImageMessage(5),
450
];
451
expect(() => filterImages(endpoint, messages, 2)).toThrow(/Too many images/);
452
});
453
});
454
});
455