Path: blob/main/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts
13405 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 { Raw } from '@vscode/prompt-tsx';6import { beforeEach, describe, expect, it } from 'vitest';7import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';8import { IAuthenticationService } from '../../../authentication/common/authentication';9import { IChatMLFetcher } from '../../../chat/common/chatMLFetcher';1011import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService';12import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService';13import { ICAPIClientService } from '../../../endpoint/common/capiClient';14import { IDomainService } from '../../../endpoint/common/domainService';15import { IChatModelInformation, ModelSupportedEndpoint } from '../../../endpoint/common/endpointProvider';16import { IEnvService } from '../../../env/common/envService';17import { ILogService } from '../../../log/common/logService';18import { IFetcherService } from '../../../networking/common/fetcherService';19import { ICreateEndpointBodyOptions } from '../../../networking/common/networking';20import { IChatWebSocketManager } from '../../../networking/node/chatWebSocketManager';21import { NullExperimentationService } from '../../../telemetry/common/nullExperimentationService';22import { ITelemetryService } from '../../../telemetry/common/telemetry';23import { ITokenizerProvider } from '../../../tokenizer/node/tokenizer';24import { ChatEndpoint } from '../chatEndpoint';25import { CopilotChatEndpoint } from '../copilotChatEndpoint';2627// Test fixtures for thinking content28const createThinkingMessage = (thinkingId: string, thinkingText: string): Raw.ChatMessage => ({29role: Raw.ChatRole.Assistant,30content: [31{32type: Raw.ChatCompletionContentPartKind.Opaque,33value: {34type: 'thinking',35thinking: {36id: thinkingId,37text: thinkingText38}39}40}41]42});4344const createTestOptions = (messages: Raw.ChatMessage[]): ICreateEndpointBodyOptions => ({45debugName: 'test',46messages,47requestId: 'test-req-123',48postOptions: {},49finishedCb: undefined,50location: undefined as any51});5253// Mock implementations54const createMockServices = () => ({55fetcherService: {} as IFetcherService,56domainService: {} as IDomainService,57capiClientService: {} as ICAPIClientService,58envService: {} as IEnvService,59telemetryService: {} as ITelemetryService,60authService: {} as IAuthenticationService,61chatMLFetcher: {} as IChatMLFetcher,62tokenizerProvider: {} as ITokenizerProvider,63instantiationService: {} as IInstantiationService,64configurationService: new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()),65expService: new NullExperimentationService(),66chatWebSocketService: {} as IChatWebSocketManager,67logService: {} as ILogService68});69707172const createNonAnthropicModelMetadata = (family: string): IChatModelInformation => ({73id: `${family}-test`,74vendor: `${family} Vendor`,75name: `${family} Test Model`,76version: '1.0',77model_picker_enabled: true,78is_chat_default: false,79is_chat_fallback: false,80capabilities: {81type: 'chat',82family: family,83tokenizer: 'o200k_base' as any,84supports: {85parallel_tool_calls: true,86streaming: true,87tool_calls: true,88vision: false,89prediction: false,90thinking: false91},92limits: {93max_prompt_tokens: 8192,94max_output_tokens: 4096,95max_context_window_tokens: 1228896}97}98});99100describe('CopilotChatEndpoint - Reasoning Properties', () => {101let mockServices: ReturnType<typeof createMockServices>;102let modelMetadata: IChatModelInformation;103104beforeEach(() => {105mockServices = createMockServices();106modelMetadata = {107id: 'copilot-base',108vendor: 'Copilot',109name: 'Copilot Base',110version: '1.0',111model_picker_enabled: true,112is_chat_default: true,113is_chat_fallback: false,114capabilities: {115type: 'chat',116family: 'copilot',117tokenizer: 'o200k_base' as any,118supports: {119parallel_tool_calls: true,120streaming: true,121tool_calls: true,122vision: false,123prediction: false,124thinking: true125},126limits: {127max_prompt_tokens: 8192,128max_output_tokens: 4096,129max_context_window_tokens: 12288130}131}132};133});134135describe('CAPI reasoning properties', () => {136it('should set reasoning_opaque and reasoning_text properties when processing thinking content', () => {137const endpoint = new CopilotChatEndpoint(138modelMetadata,139mockServices.domainService,140mockServices.capiClientService,141mockServices.fetcherService,142mockServices.envService,143mockServices.telemetryService,144mockServices.authService,145mockServices.chatMLFetcher,146mockServices.tokenizerProvider,147mockServices.instantiationService,148mockServices.configurationService,149mockServices.expService,150mockServices.chatWebSocketService,151mockServices.logService152);153154const thinkingMessage = createThinkingMessage('copilot-thinking-abc', 'copilot reasoning process');155const options = createTestOptions([thinkingMessage]);156157const body = endpoint.createRequestBody(options);158159expect(body.messages).toBeDefined();160const messages = body.messages as any[];161expect(messages).toHaveLength(1);162expect(messages[0].reasoning_opaque).toBe('copilot-thinking-abc');163expect(messages[0].reasoning_text).toBe('copilot reasoning process');164});165166it('should handle multiple messages with thinking content', () => {167const endpoint = new CopilotChatEndpoint(168modelMetadata,169mockServices.domainService,170mockServices.capiClientService,171mockServices.fetcherService,172mockServices.envService,173mockServices.telemetryService,174mockServices.authService,175mockServices.chatMLFetcher,176mockServices.tokenizerProvider,177mockServices.instantiationService,178mockServices.configurationService,179mockServices.expService,180mockServices.chatWebSocketService,181mockServices.logService182);183184const userMessage: Raw.ChatMessage = {185role: Raw.ChatRole.User,186content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Help me with code' }]187};188const thinkingMessage = createThinkingMessage('copilot-reasoning-def', 'analyzing the code request');189const options = createTestOptions([userMessage, thinkingMessage]);190191const body = endpoint.createRequestBody(options);192193expect(body.messages).toBeDefined();194const messages = body.messages as any[];195expect(messages).toHaveLength(2);196197// User message should not have reasoning properties198expect(messages[0].reasoning_opaque).toBeUndefined();199expect(messages[0].reasoning_text).toBeUndefined();200201// Assistant message should have reasoning properties202expect(messages[1].reasoning_opaque).toBe('copilot-reasoning-def');203expect(messages[1].reasoning_text).toBe('analyzing the code request');204});205206it('should handle messages without thinking content', () => {207const endpoint = new CopilotChatEndpoint(208modelMetadata,209mockServices.domainService,210mockServices.capiClientService,211mockServices.fetcherService,212mockServices.envService,213mockServices.telemetryService,214mockServices.authService,215mockServices.chatMLFetcher,216mockServices.tokenizerProvider,217mockServices.instantiationService,218mockServices.configurationService,219mockServices.expService,220mockServices.chatWebSocketService,221mockServices.logService222);223224const regularMessage: Raw.ChatMessage = {225role: Raw.ChatRole.Assistant,226content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Regular response' }]227};228const options = createTestOptions([regularMessage]);229230const body = endpoint.createRequestBody(options);231232expect(body.messages).toBeDefined();233const messages = body.messages as any[];234expect(messages).toHaveLength(1);235expect(messages[0].reasoning_opaque).toBeUndefined();236expect(messages[0].reasoning_text).toBeUndefined();237});238});239});240241describe('ChatEndpoint - Image Count Validation', () => {242let mockServices: ReturnType<typeof createMockServices>;243244beforeEach(() => {245mockServices = createMockServices();246});247248const createImageMessage = (imageCount: number = 1): Raw.ChatMessage => ({249role: Raw.ChatRole.User,250content: [251{ type: Raw.ChatCompletionContentPartKind.Text, text: 'What is in this image?' },252...Array.from({ length: imageCount }, () => ({253type: Raw.ChatCompletionContentPartKind.Image as const,254imageUrl: { url: 'data:image/png;base64,test' }255}))256]257});258259const createAssistantMessage = (): Raw.ChatMessage => ({260role: Raw.ChatRole.Assistant,261content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'I see an image.' }]262});263264const createGeminiModelMetadata = (maxPromptImages?: number): IChatModelInformation => {265const baseMetadata = createNonAnthropicModelMetadata('gemini-3');266return {267...baseMetadata,268capabilities: {269...baseMetadata.capabilities,270supports: {271...baseMetadata.capabilities.supports,272vision: true273},274limits: {275...baseMetadata.capabilities.limits,276...(maxPromptImages !== undefined ? { vision: { max_prompt_images: maxPromptImages } } : {})277}278}279};280};281282const createAnthropicMessagesModelMetadata = (): IChatModelInformation => {283const baseMetadata = createNonAnthropicModelMetadata('claude-sonnet-4');284return {285...baseMetadata,286supported_endpoints: [ModelSupportedEndpoint.Messages],287capabilities: {288...baseMetadata.capabilities,289supports: {290...baseMetadata.capabilities.supports,291vision: true292}293}294};295};296297const createEndpoint = (metadata: IChatModelInformation) =>298new ChatEndpoint(299metadata,300mockServices.domainService,301mockServices.chatMLFetcher,302mockServices.tokenizerProvider,303mockServices.instantiationService,304mockServices.configurationService,305mockServices.expService,306mockServices.chatWebSocketService,307mockServices.logService308);309310const countImages = (messages: Raw.ChatMessage[]): number => {311let count = 0;312for (const msg of messages) {313if (Array.isArray(msg.content)) {314for (const part of msg.content) {315if (part.type === Raw.ChatCompletionContentPartKind.Image) {316count++;317}318}319}320}321return count;322};323324// Exercises the private `validateAndFilterImages` method directly so we can325// assert on the filtered messages without being blocked by downstream mocks.326const filterImages = (endpoint: ChatEndpoint, messages: Raw.ChatMessage[], maxImages: number): Raw.ChatMessage[] => {327return (endpoint as unknown as { validateAndFilterImages(m: Raw.ChatMessage[], n: number): Raw.ChatMessage[] })328.validateAndFilterImages(messages, maxImages);329};330331describe('Gemini image limits', () => {332it('should allow requests within image limit', () => {333const endpoint = createEndpoint(createGeminiModelMetadata(5));334const messages = [createImageMessage(), createImageMessage()];335const options = createTestOptions(messages);336expect(() => endpoint.createRequestBody(options)).not.toThrow();337// Input is within limit — messages should be returned untouched.338expect(filterImages(endpoint, messages, 5)).toBe(messages);339});340341it('should silently filter history images when total exceeds limit', () => {342const endpoint = createEndpoint(createGeminiModelMetadata(3));343// 2 history user messages with 1 image each + current user message with 2 images = 4 total > 3 limit344const messages = [345createImageMessage(),346createAssistantMessage(),347createImageMessage(),348createAssistantMessage(),349createImageMessage(2),350];351expect(() => endpoint.createRequestBody(createTestOptions(messages))).not.toThrow();352const filtered = filterImages(endpoint, messages, 3);353// Total image parts in the filtered output must not exceed the limit.354expect(countImages(filtered)).toBeLessThanOrEqual(3);355// Current user message (last) must retain all 2 of its images.356expect(countImages([filtered[filtered.length - 1]])).toBe(2);357// Original messages must not be mutated.358expect(countImages(messages)).toBe(4);359});360});361362describe('Anthropic Messages API image limits', () => {363it('should allow requests within image limit', () => {364const endpoint = createEndpoint(createAnthropicMessagesModelMetadata());365const messages = [createImageMessage(5)];366// Within limit — filter must not alter the messages.367expect(filterImages(endpoint, messages, 20)).toBe(messages);368});369370it('should silently filter history images when total exceeds limit', () => {371const endpoint = createEndpoint(createAnthropicMessagesModelMetadata());372// Build history with 18 images + current message with 5 images = 23 total > 20 limit373const messages: Raw.ChatMessage[] = [];374for (let i = 0; i < 18; i++) {375messages.push(createImageMessage());376messages.push(createAssistantMessage());377}378messages.push(createImageMessage(5));379const filtered = filterImages(endpoint, messages, 20);380expect(countImages(filtered)).toBeLessThanOrEqual(20);381// Current user message must retain all 5 of its images.382expect(countImages([filtered[filtered.length - 1]])).toBe(5);383// Original messages must not be mutated.384expect(countImages(messages)).toBe(23);385});386});387388describe('non-limited models', () => {389it('should not apply image limits to non-Anthropic non-Gemini models', () => {390const metadata = createNonAnthropicModelMetadata('gpt-4o');391const endpoint = createEndpoint(metadata);392// 25 images should not throw for a non-limited model393const options = createTestOptions([createImageMessage(25)]);394expect(() => endpoint.createRequestBody(options)).not.toThrow();395});396});397398describe('edge cases', () => {399it('should filter tool-result images in history the same as user images', () => {400const endpoint = createEndpoint(createGeminiModelMetadata(2));401const toolResultImage: Raw.ChatMessage = {402role: Raw.ChatRole.Tool,403toolCallId: 'tool-1',404content: [405{ type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: 'https://example.com/tool.png' } }406]407};408// 2 tool-result images in history + 1 current user image = 3 total > 2 limit409const messages: Raw.ChatMessage[] = [410toolResultImage,411createAssistantMessage(),412toolResultImage,413createAssistantMessage(),414createImageMessage(1),415];416const filtered = filterImages(endpoint, messages, 2);417expect(countImages(filtered)).toBeLessThanOrEqual(2);418// Original messages must not be mutated.419expect(countImages(messages)).toBe(3);420});421422it('should ignore an overly-restrictive server-provided maxPromptImages and use the hardcoded Gemini limit of 10', () => {423// Server reports max_prompt_images: 1 but the true Gemini limit is 10.424// 2 images in the current turn must not throw.425const endpoint = createEndpoint(createGeminiModelMetadata(1));426const options = createTestOptions([createImageMessage(2)]);427expect(() => endpoint.createRequestBody(options)).not.toThrow();428});429430it('should throw using the hardcoded Gemini limit of 10 when the current turn exceeds it', () => {431const endpoint = createEndpoint(createGeminiModelMetadata(1));432const options = createTestOptions([createImageMessage(11)]);433expect(() => endpoint.createRequestBody(options)).toThrow(/maximum of 10 images/);434});435436it('should throw using the hardcoded Anthropic Messages limit of 20 when the current turn exceeds it', () => {437const endpoint = createEndpoint(createAnthropicMessagesModelMetadata());438const options = createTestOptions([createImageMessage(21)]);439expect(() => endpoint.createRequestBody(options)).toThrow(/maximum of 20 images/);440});441442it('should throw a clear error when the current turn alone exceeds the limit', () => {443const endpoint = createEndpoint(createGeminiModelMetadata(2));444// Current user message has 5 images, limit is 2. History has 1 image.445const messages = [446createImageMessage(),447createAssistantMessage(),448createImageMessage(5),449];450expect(() => filterImages(endpoint, messages, 2)).toThrow(/Too many images/);451});452});453});454455