Path: blob/main/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.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 type { OpenAI } from 'openai';7import { afterEach, beforeEach, describe, expect, it } from 'vitest';8import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';9import { CopilotToken } from '../../../../platform/authentication/common/copilotToken';10import { IFetchMLOptions } from '../../../../platform/chat/common/chatMLFetcher';11import { IChatQuotaService } from '../../../../platform/chat/common/chatQuotaService';12import { ChatLocation } from '../../../../platform/chat/common/commonTypes';13import { IInteractionService } from '../../../../platform/chat/common/interactionService';14import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';15import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';16import { ICAPIClientService } from '../../../../platform/endpoint/common/capiClient';17import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';18import { MockCAPIClientService } from '../../../../platform/ignore/node/test/mockCAPIClientService';19import { ILogService } from '../../../../platform/log/common/logService';20import { FinishedCallback } from '../../../../platform/networking/common/fetch';21import { FetcherId, IFetcherService, IHeaders, Response } from '../../../../platform/networking/common/fetcherService';22import { IChatEndpoint, IEndpointBody } from '../../../../platform/networking/common/networking';23import { NullChatWebSocketManager } from '../../../../platform/networking/node/chatWebSocketManager';24import { NoopOTelService } from '../../../../platform/otel/common/noopOtelService';25import { resolveOTelConfig } from '../../../../platform/otel/common/otelConfig';26import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';27import { NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';28import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';29import { TelemetryData } from '../../../../platform/telemetry/common/telemetryData';30import { SpyingTelemetryService } from '../../../../platform/telemetry/node/spyingTelemetryService';31import { TestLogService } from '../../../../platform/testing/common/testLogService';32import { InstantiationServiceBuilder } from '../../../../util/common/services';33import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';34import { Event } from '../../../../util/vs/base/common/event';35import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';36import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';37import { IPowerService, NullPowerService } from '../../../power/common/powerService';38import { ChatMLFetcherImpl } from '../chatMLFetcher';3940describe('ChatMLFetcherImpl Response API telemetry', () => {41let disposables: DisposableStore;42let fetcher: ChatMLFetcherImpl;43let mockFetcherService: MockFetcherService;44let spyingTelemetryService: SpyingTelemetryService;45let cancellationTokenSource: CancellationTokenSource;4647beforeEach(() => {48disposables = new DisposableStore();49cancellationTokenSource = disposables.add(new CancellationTokenSource());5051mockFetcherService = new MockFetcherService();52spyingTelemetryService = new SpyingTelemetryService();53const configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());5455const logService = new TestLogService();56const experimentationService = new NullExperimentationService();5758fetcher = new ChatMLFetcherImpl(59mockFetcherService as unknown as IFetcherService,60spyingTelemetryService,61new NullRequestLogger(),62logService,63new TestAuthenticationService() as unknown as IAuthenticationService,64createMockInteractionService(),65createMockChatQuotaService(),66new TestCAPIClientService() as unknown as ICAPIClientService,67createMockConversationOptions(),68configurationService,69experimentationService,70createMockPowerService(),71new InstantiationServiceBuilder([72[IFetcherService, mockFetcherService as unknown as IFetcherService],73[ITelemetryService, spyingTelemetryService],74[ICAPIClientService, new TestCAPIClientService() as unknown as ICAPIClientService],75]).seal() as unknown as IInstantiationService,76new NullChatWebSocketManager(),77new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),78);79});8081afterEach(() => {82disposables.dispose();83});8485it('logs non-empty messagesJson for Response API requests (input field)', async () => {86// Create an endpoint that returns Response API format (input instead of messages)87const responseApiEndpoint = createResponseApiEndpoint();8889mockFetcherService.queueResponse(createSuccessResponse('Hello!'));9091const opts: IFetchMLOptions = {92debugName: 'test-response-api',93messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello from Response API' }] }],94endpoint: responseApiEndpoint,95location: ChatLocation.Panel,96requestOptions: {},97finishedCb: undefined,98};99100await fetcher.fetchMany(opts, cancellationTokenSource.token);101102// Find the engine.messages telemetry event103const events = spyingTelemetryService.getEvents();104const engineMessagesEvents = events.telemetryServiceEvents.filter(105e => e.eventName === 'engine.messages'106);107108expect(engineMessagesEvents.length).toBeGreaterThan(0);109110// Find the input telemetry event (the one sent for input messages)111// Input events are sent in the .finally() block of _fetchWithInstrumentation112// which happens before the response processing that sends output events113const inputTelemetry = engineMessagesEvents[0]; // First event should be the input114expect(inputTelemetry).toBeDefined();115const inputProperties = inputTelemetry!.properties as Record<string, string>;116expect(inputProperties.messagesJson).toBeDefined();117118// Parse the messagesJson and verify it's not empty119const messagesJson = JSON.parse(inputProperties.messagesJson);120expect(messagesJson.length).toBeGreaterThan(0);121122// Verify the message content was properly converted from Response API input format123expect(messagesJson[0].role).toBe('user');124});125126it('logs empty messagesJson when ChatCompletion API messages array is empty', async () => {127// Create an endpoint that returns ChatCompletion API format with empty messages128const chatCompletionEndpoint = createChatCompletionEndpointWithEmptyMessages();129130mockFetcherService.queueResponse(createSuccessResponse('Hello!'));131132const opts: IFetchMLOptions = {133debugName: 'test-empty-messages',134messages: [], // Empty messages135endpoint: chatCompletionEndpoint,136location: ChatLocation.Panel,137requestOptions: {},138finishedCb: undefined,139};140141await fetcher.fetchMany(opts, cancellationTokenSource.token);142143// Find the engine.messages telemetry event144const events = spyingTelemetryService.getEvents();145const engineMessagesEvents = events.telemetryServiceEvents.filter(146e => e.eventName === 'engine.messages'147);148149// First event should be the input messages telemetry150const inputTelemetry = engineMessagesEvents[0];151152if (inputTelemetry) {153// For ChatCompletion API with empty messages, messagesJson should be "[]"154const props = inputTelemetry.properties as Record<string, string>;155const messagesJson = JSON.parse(props.messagesJson);156expect(messagesJson.length).toBe(0);157}158});159});160161// --- Test Helpers ---162163/**164* Creates an endpoint that returns Response API format request body (with input instead of messages)165*/166function createResponseApiEndpoint(): IChatEndpoint {167return {168url: 'https://api.github.com/copilot/chat/responses',169urlOrRequestMetadata: 'https://api.github.com/copilot/chat/responses',170model: 'gpt-5-mini',171modelMaxPromptTokens: 8192,172maxOutputTokens: 4096,173supportsToolCalls: true,174supportsVision: false,175supportsPrediction: false,176showInModelPicker: true,177isDefault: true,178isFallback: false,179policy: 'enabled',180getHeaders: async () => ({}),181// This is the key part - return Response API format with input instead of messages182createRequestBody: (): IEndpointBody => {183const body: IEndpointBody & OpenAI.Responses.ResponseCreateParams = {184model: 'gpt-5-mini',185stream: true,186// Response API uses 'input' instead of 'messages'187input: [188{189role: 'user',190content: [{ type: 'input_text', text: 'Hello from Response API' }]191}192],193// No 'messages' field - this is what distinguishes Response API194};195return body;196},197acquireTokenizer: () => ({198countMessagesTokens: async () => 100,199countTokens: async () => 100,200tokenize: async () => [],201}),202processResponseFromChatEndpoint: async (_telemetryService: ITelemetryService, _logService: ILogService, response: Response, _expectedNumChoices: number, finishedCb: FinishedCallback, telemetryData: TelemetryData, _cancellationToken?: CancellationToken) => {203const text = await response.text();204if (finishedCb) {205await finishedCb(text, 0, { text });206}207return {208[Symbol.asyncIterator]: async function* () {209yield {210message: { role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }] },211choiceIndex: 0,212requestId: {213headerRequestId: response.headers.get('x-request-id') || 'test-request-id',214gitHubRequestId: response.headers.get('x-github-request-id') || '',215completionId: '',216created: 0,217serverExperiments: '',218deploymentId: '',219},220tokens: [],221usage: undefined,222model: 'gpt-5-mini',223blockFinished: true,224finishReason: 'stop',225telemetryData: telemetryData,226};227}228};229},230acceptChatPolicy: async () => true,231doRequest: async () => {232throw new Error('Not implemented');233},234} as unknown as IChatEndpoint;235}236237/**238* Creates an endpoint that returns ChatCompletion API format with empty messages239*/240function createChatCompletionEndpointWithEmptyMessages(): IChatEndpoint {241return {242url: 'https://api.github.com/copilot/chat/completions',243urlOrRequestMetadata: 'https://api.github.com/copilot/chat/completions',244model: 'test-model',245modelMaxPromptTokens: 8192,246maxOutputTokens: 4096,247supportsToolCalls: true,248supportsVision: false,249supportsPrediction: false,250showInModelPicker: true,251isDefault: true,252isFallback: false,253policy: 'enabled',254getHeaders: async () => ({}),255createRequestBody: (): IEndpointBody => ({256model: 'test-model',257messages: [], // Empty messages array - ChatCompletion API format258stream: true259}),260acquireTokenizer: () => ({261countMessagesTokens: async () => 100,262countTokens: async () => 100,263tokenize: async () => [],264}),265processResponseFromChatEndpoint: async (_telemetryService: ITelemetryService, _logService: ILogService, response: Response, _expectedNumChoices: number, finishedCb: FinishedCallback, telemetryData: TelemetryData, _cancellationToken?: CancellationToken) => {266const text = await response.text();267if (finishedCb) {268await finishedCb(text, 0, { text });269}270return {271[Symbol.asyncIterator]: async function* () {272yield {273message: { role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }] },274choiceIndex: 0,275requestId: {276headerRequestId: response.headers.get('x-request-id') || 'test-request-id',277gitHubRequestId: response.headers.get('x-github-request-id') || '',278completionId: '',279created: 0,280serverExperiments: '',281deploymentId: '',282},283tokens: [],284usage: undefined,285model: 'test-model',286blockFinished: true,287finishReason: 'stop',288telemetryData: telemetryData,289};290}291};292},293acceptChatPolicy: async () => true,294doRequest: async () => {295throw new Error('Not implemented');296},297} as unknown as IChatEndpoint;298}299300class MockFetcherService {301private _responseQueue: (Response | Error)[] = [];302private _fetchCallCount = 0;303304get fetchCallCount(): number {305return this._fetchCallCount;306}307308queueResponse(response: Response): void {309this._responseQueue.push(response);310}311312queueError(error: Error): void {313this._responseQueue.push(error);314}315316async fetch(_url: string, _options?: unknown): Promise<Response> {317this._fetchCallCount++;318const next = this._responseQueue.shift();319if (!next) {320throw new Error('No more queued responses');321}322if (next instanceof Error) {323throw next;324}325return next;326}327328fetchWithPagination<T>(): Promise<T[]> {329throw new Error('Method not implemented.');330}331332disconnectAll(): Promise<void> {333return Promise.resolve();334}335336makeAbortController(): AbortController {337return new AbortController();338}339340isAbortError(_err: unknown): boolean {341return false;342}343344isInternetDisconnectedError(_err: unknown): boolean {345return false;346}347348isFetcherError(err: unknown): boolean {349return err instanceof Error && 'code' in err;350}351352getUserMessageForFetcherError(_err: unknown): string {353return 'Network error occurred';354}355356getUserAgentLibrary(): string {357return 'test-agent';358}359}360361class TestAuthenticationService extends MockAuthenticationService {362override getCopilotToken(_force?: boolean): Promise<CopilotToken> {363return Promise.resolve({364token: 'test-token',365username: 'test-user',366} as CopilotToken);367}368}369370class TestCAPIClientService extends MockCAPIClientService {371get capiPingURL(): string {372return 'https://api.github.com/copilot_internal/ping';373}374}375376function createMockInteractionService(): IInteractionService {377return {378_serviceBrand: undefined,379onInteractionStateChanged: Event.None,380sendChatInteraction: () => { },381getInteractionState: () => undefined,382interactionId: 'test-interaction-id',383} as unknown as IInteractionService;384}385386function createMockChatQuotaService(): IChatQuotaService {387return {388_serviceBrand: undefined,389processQuotaHeaders: () => { },390} as unknown as IChatQuotaService;391}392393function createMockConversationOptions() {394return {395_serviceBrand: undefined,396maxResponseTokens: 4096,397temperature: 0.5,398topP: 1,399rejectionMessage: 'rejected',400};401}402403function createMockPowerService(): IPowerService {404return new NullPowerService();405}406407class FakeHeaders implements IHeaders {408constructor(private readonly headers = new Map<string, string>()) { }409get(name: string): string | null {410return this.headers.get(name.toLowerCase()) ?? null;411}412*[Symbol.iterator](): Iterator<[string, string]> {413yield* this.headers.entries();414}415}416417function createSuccessResponse(content: string): Response {418const streamContent = `data: {"choices":[{"delta":{"content":"${content}"},"index":0}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop","index":0}]}\n\ndata: [DONE]\n\n`;419return Response.fromText(420200,421'OK',422new FakeHeaders(new Map([423['content-type', 'text/event-stream'],424['x-request-id', 'test-request-id'],425])),426streamContent,427'node-fetch' as FetcherId428);429}430431432