From 7e2c4adadf8ce8ac54adfb0a69b93e93af79d506 Mon Sep 17 00:00:00 2001 From: Dileep Yavanmandha Date: Mon, 2 Nov 2026 11:32:52 -0800 Subject: [PATCH] copilot: preserve HTTP correlation for incomplete Responses streams Retain HTTP request metadata independently of streamed completions so the no-choices failure retains diagnostic correlation. Keep completion-derived metadata and existing response classifications unchanged. Fixes #344706 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extension/prompt/node/chatMLFetcher.ts | 7 +- .../chatMLFetcherResponseApiTelemetry.spec.ts | 85 +++++++++++++++++-- .../copilot/src/platform/openai/node/fetch.ts | 2 + 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index f65b19ca12e4fe..8c9448958cab1f 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -1499,6 +1499,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { result: { type: FetchResponseKind.Success, chatCompletions, + modelRequestId: getRequestId(response.headers), }, fetcher: response.fetcher, bytesReceived: response.bytesReceived, @@ -2002,9 +2003,9 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { type: ChatFetchResponseType.Unknown, reason: RESPONSE_CONTAINED_NO_CHOICES, requestId: requestId, - serverRequestId: result?.requestId.headerRequestId, - copilotServiceRequestId: result?.requestId.copilotServiceRequestId, - ...gitHubCopilotRequestTeProperty(result?.requestId.gitHubCopilotRequestTe), + serverRequestId: result?.requestId.headerRequestId ?? (response.modelRequestId?.headerRequestId || undefined), + copilotServiceRequestId: result?.requestId.copilotServiceRequestId ?? (response.modelRequestId?.copilotServiceRequestId || undefined), + ...gitHubCopilotRequestTeProperty(result?.requestId.gitHubCopilotRequestTe ?? response.modelRequestId?.gitHubCopilotRequestTe), }; } diff --git a/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts b/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts index 988db33998eb51..d8a75c323cc7b1 100644 --- a/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts +++ b/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts @@ -10,11 +10,12 @@ import { IAuthenticationService } from '../../../../platform/authentication/comm import { CopilotToken } from '../../../../platform/authentication/common/copilotToken'; import { IFetchMLOptions } from '../../../../platform/chat/common/chatMLFetcher'; import { IChatQuotaService } from '../../../../platform/chat/common/chatQuotaService'; -import { ChatLocation } from '../../../../platform/chat/common/commonTypes'; +import { ChatFetchResponseType, ChatLocation, RESPONSE_CONTAINED_NO_CHOICES } from '../../../../platform/chat/common/commonTypes'; import { IInteractionService } from '../../../../platform/chat/common/interactionService'; import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService'; import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService'; import { ICAPIClientService } from '../../../../platform/endpoint/common/capiClient'; +import { processResponseFromChatEndpoint } from '../../../../platform/endpoint/node/responsesApi'; import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService'; import { MockCAPIClientService } from '../../../../platform/ignore/node/test/mockCAPIClientService'; import { ILogService } from '../../../../platform/log/common/logService'; @@ -44,6 +45,7 @@ describe('ChatMLFetcherImpl Response API telemetry', () => { let mockFetcherService: MockFetcherService; let spyingTelemetryService: SpyingTelemetryService; let cancellationTokenSource: CancellationTokenSource; + let instantiationService: IInstantiationService; beforeEach(() => { disposables = new DisposableStore(); @@ -55,6 +57,12 @@ describe('ChatMLFetcherImpl Response API telemetry', () => { const logService = new TestLogService(); const experimentationService = new NullExperimentationService(); + instantiationService = disposables.add(new InstantiationServiceBuilder([ + [IFetcherService, mockFetcherService], + [ITelemetryService, spyingTelemetryService], + [ICAPIClientService, new TestCAPIClientService()], + [ILogService, logService], + ]).seal()); fetcher = new ChatMLFetcherImpl( mockFetcherService as unknown as IFetcherService, @@ -69,11 +77,7 @@ describe('ChatMLFetcherImpl Response API telemetry', () => { configurationService, experimentationService, createMockPowerService(), - new InstantiationServiceBuilder([ - [IFetcherService, mockFetcherService as unknown as IFetcherService], - [ITelemetryService, spyingTelemetryService], - [ICAPIClientService, new TestCAPIClientService() as unknown as ICAPIClientService], - ]).seal() as unknown as IInstantiationService, + instantiationService, new NullChatWebSocketManager(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), ); @@ -83,6 +87,75 @@ describe('ChatMLFetcherImpl Response API telemetry', () => { disposables.dispose(); }); + const reasoningEvent = `event: response.output_item.done\ndata: ${JSON.stringify({ + type: 'response.output_item.done', + output_index: 0, + item: { type: 'reasoning', id: 'rs_test', summary: [], encrypted_content: 'synthetic-only' }, + })}\n\n`; + const completedEvent = `event: response.completed\ndata: ${JSON.stringify({ + type: 'response.completed', + response: { id: 'resp_test', model: 'gpt-5-mini', created_at: 0, output: [] }, + })}\n\n`; + const errorEvent = `event: error\ndata: ${JSON.stringify({ + type: 'error', code: 'server_error', message: 'Synthetic stream failure', param: null, + })}\n\n`; + + it.each([ + ['empty body', '', ChatFetchResponseType.Unknown], + ['reasoning followed by EOF', reasoningEvent, ChatFetchResponseType.Unknown], + ['unterminated completed frame', reasoningEvent + completedEvent.trimEnd(), ChatFetchResponseType.Unknown], + ['completed response', reasoningEvent + completedEvent, ChatFetchResponseType.Success], + ['explicit stream error', reasoningEvent + errorEvent, ChatFetchResponseType.Failed], + ])('retains HTTP correlation for %s without changing classification', async (_name, body, type) => { + const endpoint: IChatEndpoint = { + ...createResponseApiEndpoint(), + processResponseFromChatEndpoint: (telemetryService, logService, response, expectedNumChoices, finishedCb, telemetryData) => + processResponseFromChatEndpoint(instantiationService, telemetryService, logService, response, expectedNumChoices, finishedCb, telemetryData), + }; + mockFetcherService.queueResponse(Response.fromText(200, 'OK', new FakeHeaders(new Map([ + ['content-type', 'text/event-stream'], + ['x-request-id', 'synthetic-header-request-id'], + ['x-github-request-id', 'different-github-request-id'], + ['x-copilot-service-request-id', 'synthetic-service-request-id'], + ['x-github-copilot-request-te', 'false'], + ])), body, 'test-stub')); + + const result = await fetcher.fetchMany(createHelloOpts(endpoint), cancellationTokenSource.token); + + expect({ + type: result.type, + serverRequestId: result.serverRequestId, + copilotServiceRequestId: result.copilotServiceRequestId, + gitHubCopilotRequestTe: result.gitHubCopilotRequestTe, + ...(type === ChatFetchResponseType.Unknown ? { reason: 'reason' in result ? result.reason : undefined } : {}), + }).toEqual({ + type, + serverRequestId: 'synthetic-header-request-id', + copilotServiceRequestId: 'synthetic-service-request-id', + gitHubCopilotRequestTe: 'false', + ...(type === ChatFetchResponseType.Unknown ? { reason: RESPONSE_CONTAINED_NO_CHOICES } : {}), + }); + }); + + it('does not fabricate HTTP correlation when an incomplete stream has no headers', async () => { + const endpoint: IChatEndpoint = { + ...createResponseApiEndpoint(), + processResponseFromChatEndpoint: (telemetryService, logService, response, expectedNumChoices, finishedCb, telemetryData) => + processResponseFromChatEndpoint(instantiationService, telemetryService, logService, response, expectedNumChoices, finishedCb, telemetryData), + }; + mockFetcherService.queueResponse(Response.fromText(200, 'OK', new HeadersImpl({}), reasoningEvent, 'test-stub')); + + const result = await fetcher.fetchMany(createHelloOpts(endpoint), cancellationTokenSource.token); + + expect(result).toMatchObject({ + type: ChatFetchResponseType.Unknown, + reason: RESPONSE_CONTAINED_NO_CHOICES, + serverRequestId: undefined, + copilotServiceRequestId: undefined, + }); + expect('gitHubCopilotRequestTe' in result).toBe(false); + }); + it('logs non-empty messagesJson for Response API requests (input field)', async () => { // Create an endpoint that returns Response API format (input instead of messages) const responseApiEndpoint = createResponseApiEndpoint(); diff --git a/extensions/copilot/src/platform/openai/node/fetch.ts b/extensions/copilot/src/platform/openai/node/fetch.ts index 77a316cbcb6c69..dea146930feba6 100644 --- a/extensions/copilot/src/platform/openai/node/fetch.ts +++ b/extensions/copilot/src/platform/openai/node/fetch.ts @@ -15,6 +15,8 @@ export enum FetchResponseKind { export interface ChatResults { type: FetchResponseKind.Success; chatCompletions: AsyncIterable; + /** HTTP correlation retained even when the stream emits no completions. */ + modelRequestId?: RequestId; } export interface ChatRequestFailed {