Path: blob/main/extensions/copilot/src/platform/networking/node/stream.ts
13401 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 type { CancellationToken } from 'vscode';6import { ILogService, LogLevel } from '../../log/common/logService';7import { ITelemetryService } from '../../telemetry/common/telemetry';8import { TelemetryData } from '../../telemetry/common/telemetryData';9import { RawThinkingDelta, ThinkingDelta } from '../../thinking/common/thinking';10import { extractThinkingDeltaFromChoice, } from '../../thinking/common/thinkingUtils';11import { FinishedCallback, getRequestId, ICodeVulnerabilityAnnotation, ICopilotBeginToolCall, ICopilotConfirmation, ICopilotError, ICopilotFunctionCall, ICopilotReference, ICopilotToolCall, ICopilotToolCallStreamUpdate, IIPCodeCitation, isCodeCitationAnnotation, isCopilotAnnotation, RequestId } from '../common/fetch';12import { DestroyableStream, Response } from '../common/fetcherService';13import { APIErrorResponse, APIJsonData, APIUsage, ChoiceLogProbs, FilterReason, FinishedCompletionReason, isApiUsage, IToolCall } from '../common/openai';1415/** Gathers together many chunks of a single completion choice. */16class APIJsonDataStreaming {1718constructor(public readonly model: string) { }1920get text(): readonly string[] {21return this._text;22}2324private _text: string[] = [];25private _newText: string[] = [];2627append(choice: ExtendedChoiceJSON) {28if (choice.text) {29const str = APIJsonDataStreaming._removeCR(choice.text);30this._text.push(str);31this._newText.push(str);32}33if (choice.delta?.content) {34const str = APIJsonDataStreaming._removeCR(choice.delta.content);35this._text.push(str);36this._newText.push(str);37}38if (choice.delta?.function_call && (choice.delta.function_call.name || choice.delta.function_call.arguments)) {39const str = APIJsonDataStreaming._removeCR(choice.delta.function_call.arguments);40this._text.push(str);41this._newText.push(str);42}43}4445flush(): string {46const delta = this._newText.join('');47this._newText = [];48return delta;49}5051private static _removeCR(text: string): string {52return text.replace(/\r$/g, '');53}5455toJSON() {56return {57text: this._text,58newText: this._newText59};60}61}6263class StreamingToolCall {64public id: string | undefined;65public name: string | undefined;66public arguments: string = '';6768constructor() { }6970update(toolCall: IToolCall): boolean {71let argumentsChanged = false;7273if (toolCall.id) {74this.id = toolCall.id;75}7677if (toolCall.function?.name) {78this.name = toolCall.function.name;79}8081if (toolCall.function?.arguments) {82this.arguments += toolCall.function.arguments;83argumentsChanged = true;84}8586return argumentsChanged;87}88}8990class StreamingToolCalls {91private toolCalls: StreamingToolCall[] = [];9293constructor() { }9495getToolCalls(): ICopilotToolCall[] {96return this.toolCalls.map(call => {97return {98name: call.name!,99arguments: call.arguments,100id: call.id!,101};102});103}104105hasToolCalls(): boolean {106return this.toolCalls.length > 0;107}108109update(choice: ExtendedChoiceJSON): ICopilotToolCallStreamUpdate[] {110const updates: ICopilotToolCallStreamUpdate[] = [];111choice.delta?.tool_calls?.forEach(toolCall => {112let currentCall: StreamingToolCall | undefined;113if (toolCall.id) {114currentCall = this.toolCalls.find(call => call.id === toolCall.id);115}116if (!currentCall) {117currentCall = this.toolCalls.at(-1);118}119if (!currentCall || (toolCall.id && currentCall.id && currentCall.id !== toolCall.id)) {120currentCall = new StreamingToolCall();121this.toolCalls.push(currentCall);122}123124const argumentsChanged = currentCall.update(toolCall);125if (argumentsChanged && currentCall.name) {126updates.push({127name: currentCall.name,128arguments: currentCall.arguments,129id: currentCall.id,130});131}132});133return updates;134}135}136137// Given a string of lines separated by one or more newlines, returns complete138// lines and any remaining partial line data. Exported for test only.139export function splitChunk(chunk: string): [string[], string] {140const dataLines = chunk.split('\n');141const newExtra = dataLines.pop(); // will be empty string if chunk ends with "\n"142return [dataLines.filter(line => line !== ''), newExtra!];143}144145/**146* A single finished completion returned from the model or proxy, along with147* some metadata.148*/149export interface FinishedCompletion {150solution: APIJsonDataStreaming;151/** An optional offset into `solution.text.join('')` where the completion finishes. */152finishOffset: number | undefined;153/** A copilot-specific human-readable reason for the completion finishing. */154reason: FinishedCompletionReason;155/** A copilot-specific reason for filtering the response. Only returns when reason === FinishedCompletionReason.ContentFilter */156filterReason?: FilterReason;157error?: APIErrorResponse;158/** The token usage reported from CAPI */159usage?: APIUsage;160requestId: RequestId;161index: number;162}163164/** What comes back from the OpenAI API for a single choice in an SSE chunk. */165interface ChoiceJSON {166index: number;167/**168* The text attribute as defined in completions streaming.169* See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format170*/171text?: string;172/**173* The delta attribute as defined in chat streaming.174* See https://github.com/openai/openai-cookbook/blob/main/examples/How_to_stream_completions.ipynb175*/176delta?: { content: string | null };177finish_reason?: FinishedCompletionReason.Stop | FinishedCompletionReason.Length | FinishedCompletionReason.FunctionCall | FinishedCompletionReason.ContentFilter | FinishedCompletionReason.ServerError | FinishedCompletionReason.ToolCalls | null;178logprobs?: ChoiceLogProbs;179}180181/**182* Extensions to the OpenAI stream format183*/184interface ExtendedChoiceJSON extends ChoiceJSON {185content_filter_results?: Record<Exclude<FilterReason, FilterReason.Copyright>, { filtered: boolean; severity: string }>;186message?: RawThinkingDelta;187delta?: {188content: string | null;189copilot_annotations?: {190CodeVulnerability: ICodeVulnerabilityAnnotation[];191IPCodeCitations: IIPCodeCitation[];192TextCopyright: boolean | undefined;193Sexual: boolean | undefined;194SexualPattern: boolean | undefined;195Violence: boolean | undefined;196HateSpeech: boolean | undefined;197HateSpeechPattern: boolean | undefined;198SelfHarm: boolean | undefined;199PromptPromBlockList: boolean | undefined;200};201function_call?: { name: string; arguments: string };202tool_calls?: IToolCall[];203role?: string;204name?: string;205} & RawThinkingDelta;206}207208/**209* Processes an HTTP request containing what is assumed to be an SSE stream of210* OpenAI API data. Yields a stream of `FinishedCompletion` objects, each as211* soon as it's finished.212*/213export class SSEProcessor {214private requestId: RequestId = getRequestId(this.response.headers);215/**216* A key & value being here means at least one chunk with that choice index217* has been received. A null value means we've already finished the given218* solution and should not process incoming tokens further.219*/220private readonly solutions: Record<number, APIJsonDataStreaming | null> = {};221222private readonly completedFunctionCallIdxs: Map<number /* index */, 'function' | 'tool'> = new Map();223private readonly functionCalls: Record<string, APIJsonDataStreaming | null> = {};224private readonly toolCalls = new StreamingToolCalls();225private functionCallName: string | undefined = undefined;226227private constructor(228private readonly logService: ILogService,229private readonly telemetryService: ITelemetryService,230private readonly expectedNumChoices: number,231private readonly response: Response,232private readonly body: DestroyableStream<string>,233private readonly cancellationToken?: CancellationToken234) { }235236static async create(237logService: ILogService,238telemetryService: ITelemetryService,239expectedNumChoices: number,240response: Response,241cancellationToken?: CancellationToken242) {243const body = response.body.pipeThrough(new TextDecoderStream());244return new SSEProcessor(245logService,246telemetryService,247expectedNumChoices,248response,249body,250cancellationToken251);252}253254/**255* Yields finished completions as soon as they are available. The finishedCb256* is used to determine when a completion is done and should be truncated.257* It is called on the whole of the received solution text, once at the end258* of the completion (if it stops by itself) and also on any chunk that has259* a newline in it.260*261* Closes the server request stream when all choices are finished/truncated262* (as long as fastCancellation is true).263*264* Note that for this to work, the caller must consume the entire stream.265* This happens automatically when using a `for await` loop, but when266* iterating manually this needs to be done by calling `.next()` until it267* returns an item with done = true (or calling `.return()`).268*/269async *processSSE(finishedCb: FinishedCallback = async () => undefined): AsyncIterable<FinishedCompletion> {270try {271// If it's n > 1 we don't handle usage as the usage is global for the stream and all our code assumes per choice272// Therefore we will just skip over the usage and yield the completions273if (this.expectedNumChoices > 1) {274for await (const usageOrCompletions of this.processSSEInner(finishedCb)) {275if (!isApiUsage(usageOrCompletions)) {276yield usageOrCompletions;277}278}279} else {280let completion: FinishedCompletion | undefined;281let usage: APIUsage | undefined;282283// Process both the usage and the completions, then yield one combined completions284for await (const usageOrCompletions of this.processSSEInner(finishedCb)) {285if (isApiUsage(usageOrCompletions)) {286usage = usageOrCompletions;287} else {288completion = usageOrCompletions;289}290}291292if (await this.maybeCancel('after receiving the completion, but maybe before we got the usage')) {293return;294}295296if (completion) {297completion.usage = usage;298yield completion;299}300}301} finally {302await this.cancel();303this.logService.info(304`request done: requestId: [${this.requestId.headerRequestId}] model deployment ID: [${this.requestId.deploymentId}]`305);306}307}308309private async *processSSEInner(finishedCb: FinishedCallback): AsyncIterable<FinishedCompletion | APIUsage> {310// Collects pieces of the SSE stream that haven't been fully processed yet.311let extraData = '';312// This flag is set when at least for one solution we finished early (via `finishedCb`).313let hadEarlyFinishedSolution = false;314315// The platform agent can return a 'function_call' finish_reason, which isn't a real function call316// but is echoing internal function call messages back to us. So don't treat them as real function calls317// if we received more data after that318let allowCompletingSolution = true;319let thinkingFound = false;320321// Iterate over arbitrarily sized chunks coming in from the network.322for await (const chunk of this.body) {323if (await this.maybeCancel('after awaiting body chunk')) {324return;325}326327// this.logService.debug(chunk.toString());328const [dataLines, remainder] = splitChunk(extraData + chunk.toString());329extraData = remainder;330331// Each dataLine is complete since we've seen at least one \n after it332333for (const dataLine of dataLines) {334// Lines which start with a `:` are SSE Comments per the spec and can be ignored335if (dataLine.startsWith(':')) {336continue;337}338const lineWithoutData = dataLine.slice('data:'.length).trim();339if (lineWithoutData === '[DONE]') {340yield* this.finishSolutions();341return;342}343344// TODO @lramos15 - This should not be an ugly inlined type like this345let json: {346choices: ExtendedChoiceJSON[] | undefined | null;347model: string;348error?: APIErrorResponse;349copilot_references?: any;350copilot_confirmation?: any;351copilot_errors: any;352usage: APIUsage | undefined;353};354try {355json = JSON.parse(lineWithoutData);356} catch (e) {357this.logService.error(`Error parsing JSON stream data for request id ${this.requestId.headerRequestId}:${dataLine}`);358sendCommunicationErrorTelemetry(this.telemetryService, `Error parsing JSON stream data for request id ${this.requestId.headerRequestId}:`, dataLine);359continue;360}361362// Track usage data for this stream. Usage is global and not per choice. Therefore it's emitted as its own chunk363if (json.usage) {364yield json.usage;365}366367// A message with a confirmation may or may not have 'choices'368if (json.copilot_confirmation && isCopilotConfirmation(json.copilot_confirmation)) {369await finishedCb('', 0, { text: '', copilotConfirmation: json.copilot_confirmation });370}371372if (!json.choices) {373// Currently there are messages with a null 'choices' that include copilot_references- ignore these374if (!json.copilot_references && !json.copilot_confirmation) {375if (json.error !== undefined) {376this.logService.error(`Error in response for request id ${this.requestId.headerRequestId}:${json.error.message}`);377sendCommunicationErrorTelemetry(this.telemetryService, `Error in response for request id ${this.requestId.headerRequestId}:`, json.error.message);378// Encountered an error mid stream we immediately yield as the response is not usable.379yield {380index: 0,381finishOffset: undefined,382solution: new APIJsonDataStreaming(json.model || ''),383reason: FinishedCompletionReason.ServerError,384error: json.error,385requestId: this.requestId,386};387} else {388this.logService.error(`Unexpected response with no choices or error for request id ${this.requestId.headerRequestId}`);389sendCommunicationErrorTelemetry(this.telemetryService, `Unexpected response with no choices or error for request id ${this.requestId.headerRequestId}`);390}391}392393// There are also messages with a null 'choices' that include copilot_errors- report these394if (json.copilot_errors) {395await finishedCb('', 0, { text: '', copilotErrors: json.copilot_errors });396}397398if (json.copilot_references) {399await finishedCb('', 0, { text: '', copilotReferences: json.copilot_references });400}401402continue;403}404405if (this.requestId.created === 0) {406// Would only be 0 if we're the first actual response chunk407this.requestId = getRequestId(this.response.headers, json);408if (this.requestId.created === 0 && json.choices?.length) { // An initial chunk is sent with an empty choices array and no id, to hold `prompt_filter_results`409this.requestId.created = Math.floor(Date.now() / 1000);410}411}412413for (let i = 0; i < json.choices.length; i++) {414const choice = json.choices[i];415416this.logChoice(choice);417418419const thinkingDelta = extractThinkingDeltaFromChoice(choice);420421// Once we observe any thinking text or an id in this batch, keep the flag true422thinkingFound ||= !!(thinkingDelta?.text || thinkingDelta?.id);423424if (!(choice.index in this.solutions)) {425this.solutions[choice.index] = new APIJsonDataStreaming(json.model);426}427428const solution = this.solutions[choice.index];429if (solution === null) {430if (thinkingDelta) {431await finishedCb('', choice.index, { text: '', thinking: thinkingDelta });432}433continue; // already finished434}435436let finishOffset: number | undefined;437438const emitSolution = async (delta?: { vulnAnnotations?: ICodeVulnerabilityAnnotation[]; ipCodeCitations?: IIPCodeCitation[]; references?: ICopilotReference[]; toolCalls?: ICopilotToolCall[]; toolCallStreamUpdates?: ICopilotToolCallStreamUpdate[]; functionCalls?: ICopilotFunctionCall[]; errors?: ICopilotError[]; beginToolCalls?: ICopilotBeginToolCall[]; thinking?: ThinkingDelta }) => {439if (delta?.vulnAnnotations && (!Array.isArray(delta.vulnAnnotations) || !delta.vulnAnnotations.every(a => isCopilotAnnotation(a)))) {440delta.vulnAnnotations = undefined;441}442443// Validate code citation annotations carefully, because the API is a work in progress444if (delta?.ipCodeCitations && (!Array.isArray(delta.ipCodeCitations) || !delta.ipCodeCitations.every(isCodeCitationAnnotation))) {445delta.ipCodeCitations = undefined;446}447448finishOffset = await finishedCb(solution.text.join(''), choice.index, {449text: solution.flush(),450logprobs: choice.logprobs,451codeVulnAnnotations: delta?.vulnAnnotations,452ipCitations: delta?.ipCodeCitations,453copilotReferences: delta?.references,454copilotToolCalls: delta?.toolCalls,455copilotToolCallStreamUpdates: delta?.toolCallStreamUpdates,456_deprecatedCopilotFunctionCalls: delta?.functionCalls,457beginToolCalls: delta?.beginToolCalls,458copilotErrors: delta?.errors,459thinking: thinkingDelta ?? delta?.thinking,460});461if (finishOffset !== undefined) {462hadEarlyFinishedSolution = true;463}464return await this.maybeCancel('after awaiting finishedCb');465};466467let handled = true;468if (choice.delta?.tool_calls) {469const hadExistingToolCalls = this.toolCalls.hasToolCalls();470if (!hadExistingToolCalls) {471const firstToolCall = choice.delta.tool_calls.at(0);472const firstToolName = firstToolCall?.function?.name;473if (firstToolName) {474if (solution.text.length) {475// Flush the linkifier stream. See #16465476solution.append({ index: 0, delta: { content: ' ' } });477}478if (await emitSolution({ beginToolCalls: [{ name: firstToolName, id: firstToolCall?.id }] })) {479continue;480}481}482}483const toolCallStreamUpdates = this.toolCalls.update(choice);484if (toolCallStreamUpdates.length) {485if (await emitSolution({ toolCallStreamUpdates })) {486continue;487}488}489} else if (choice.delta?.copilot_annotations?.CodeVulnerability || choice.delta?.copilot_annotations?.IPCodeCitations) {490if (await emitSolution()) {491continue;492}493494if (!hadEarlyFinishedSolution) {495solution.append(choice);496if (await emitSolution({ vulnAnnotations: choice.delta?.copilot_annotations?.CodeVulnerability, ipCodeCitations: choice.delta?.copilot_annotations?.IPCodeCitations })) {497continue;498}499}500} else if (choice.delta?.role === 'function') {501if (choice.delta.content) {502try {503const references = JSON.parse(choice.delta.content);504if (Array.isArray(references)) {505if (await emitSolution({ references: references })) {506continue;507}508}509} catch (ex) {510this.logService.error(`Error parsing function references: ${JSON.stringify(ex)}`);511}512}513} else if (choice.delta?.function_call && (choice.delta.function_call.name || choice.delta.function_call.arguments)) {514allowCompletingSolution = false;515this.functionCallName ??= choice.delta.function_call.name;516this.functionCalls[this.functionCallName] ??= new APIJsonDataStreaming(json.model);517const functionCall = this.functionCalls[this.functionCallName];518functionCall!.append(choice);519} else if ((choice.finish_reason === FinishedCompletionReason.FunctionCall || choice.finish_reason === FinishedCompletionReason.Stop) && this.functionCallName) {520// We don't want to yield the function call until we have all the data521const functionCallStreamObj = this.functionCalls[this.functionCallName];522const functionCall = { name: this.functionCallName, arguments: functionCallStreamObj!.flush() };523this.completedFunctionCallIdxs.set(choice.index, 'function');524try {525if (await emitSolution({ functionCalls: [functionCall] })) {526continue;527}528} catch (error) {529this.logService.error(error);530}531532this.functionCalls[this.functionCallName] = null;533this.functionCallName = undefined;534if (choice.finish_reason === FinishedCompletionReason.FunctionCall) {535// See note about the 'function_call' finish_reason below536continue;537}538} else {539handled = false;540}541542if ((choice.finish_reason === FinishedCompletionReason.ToolCalls || choice.finish_reason === FinishedCompletionReason.Stop) && this.toolCalls.hasToolCalls()) {543handled = true;544const toolCalls = this.toolCalls.getToolCalls();545this.completedFunctionCallIdxs.set(choice.index, 'tool');546const toolId = toolCalls.length > 0 ? toolCalls[0].id : undefined;547try {548if (await emitSolution({ toolCalls: toolCalls, thinking: (toolId && thinkingFound) ? { metadata: { toolId } } : undefined })) {549continue;550}551} catch (error) {552this.logService.error(error);553}554}555556if (!handled) {557solution.append(choice);558559// Call finishedCb to determine if the solution is now complete.560if (await emitSolution()) {561continue;562}563}564565const solutionDone = Boolean(choice.finish_reason) || finishOffset !== undefined;566if (!solutionDone) {567continue;568}569// NOTE: When there is a finish_reason the text of subsequent chunks is always '',570// (current chunk might still have useful text, that is why we add it above).571// So we know that we already got all the text to be displayed for the user.572// TODO: This might contain additional logprobs for excluded next tokens. We should573// filter out indices that correspond to excluded tokens. It will not affect the574// text though.575yield {576solution,577finishOffset,578reason: choice.finish_reason ?? FinishedCompletionReason.ClientTrimmed,579filterReason: choiceToFilterReason(choice),580requestId: this.requestId,581index: choice.index,582};583584if (await this.maybeCancel('after yielding finished choice')) {585return;586}587588if (allowCompletingSolution) {589this.solutions[choice.index] = null;590}591}592}593}594595// Yield whatever solutions remain incomplete in case no [DONE] was received.596// This shouldn't happen in practice unless there was an error somewhere.597for (const [index, solution] of Object.entries(this.solutions)) {598const solutionIndex = Number(index); // Convert `index` from string to number599if (solution === null) {600continue; // already finished601}602yield {603solution,604finishOffset: undefined,605reason: FinishedCompletionReason.ClientIterationDone,606requestId: this.requestId,607index: solutionIndex,608};609610if (await this.maybeCancel('after yielding after iteration done')) {611return;612}613}614615// Error message can be present in `extraData`616//617// When `finishedCb` decides to finish a solution early, it is possible that618// we will have unfinished or partial JSON data in `extraData` because we619// break out of the above for loop as soon as all solutions are finished.620//621// We don't want to alarm ourselves with such partial JSON data.622if (extraData.length > 0 && !hadEarlyFinishedSolution) {623try {624const extraDataJson = JSON.parse(extraData);625if (extraDataJson.error !== undefined) {626this.logService.error(extraDataJson.error, `Error in response: ${extraDataJson.error.message}`);627sendCommunicationErrorTelemetry(this.telemetryService, `Error in response: ${extraDataJson.error.message}`, extraDataJson.error);628}629} catch (e) {630this.logService.error(`Error parsing extraData for request id ${this.requestId.headerRequestId}: ${extraData}`);631sendCommunicationErrorTelemetry(this.telemetryService, `Error parsing extraData for request id ${this.requestId.headerRequestId}: ${extraData}`);632}633}634}635636/** Yields the solutions that weren't yet finished, with a 'DONE' reason. */637private async *finishSolutions(): AsyncIterable<FinishedCompletion> {638for (const [index, solution] of Object.entries(this.solutions)) {639const solutionIndex = Number(index); // Convert `index` from string to number640if (solution === null) {641continue; // already finished642}643if (this.completedFunctionCallIdxs.has(solutionIndex)) {644yield {645solution,646finishOffset: undefined,647reason: this.completedFunctionCallIdxs.get(solutionIndex) === 'function' ? FinishedCompletionReason.FunctionCall : FinishedCompletionReason.ToolCalls,648requestId: this.requestId,649index: solutionIndex,650};651continue;652}653yield {654solution,655finishOffset: undefined,656reason: FinishedCompletionReason.ClientDone,657requestId: this.requestId,658index: solutionIndex,659};660661if (await this.maybeCancel('after yielding on DONE')) {662return;663}664}665}666667/**668* Returns whether the cancellation token was cancelled and closes the669* stream if it was.670*/671private async maybeCancel(description: string) {672if (this.cancellationToken?.isCancellationRequested) {673this.logService.debug('Cancelled: ' + description);674await this.cancel();675return true;676}677return false;678}679680private async cancel() {681await this.response.body.destroy();682}683684private logChoice(choice: ExtendedChoiceJSON) {685const choiceCopy: any = { ...choice };686delete choiceCopy.index;687delete choiceCopy.content_filter_results;688delete choiceCopy.content_filter_offsets;689this.logService.trace(`choice ${JSON.stringify(choiceCopy)}`);690}691}692693// data: {"choices":null,"copilot_confirmation":{"type":"action","title":"Are you sure you want to proceed?","message":"This action is irreversible.","confirmation":{"id":"123"}},"id":null}694function isCopilotConfirmation(obj: unknown): obj is ICopilotConfirmation {695return typeof (obj as ICopilotConfirmation).title === 'string' &&696typeof (obj as ICopilotConfirmation).message === 'string' &&697!!(obj as ICopilotConfirmation).confirmation;698}699700// Function to convert from APIJsonDataStreaming to APIJsonData format701export function convertToAPIJsonData(streamingData: APIJsonDataStreaming): APIJsonData {702const joinedText = streamingData.text.join('');703const out: APIJsonData = {704text: joinedText,705tokens: streamingData.text,706};707return out;708}709710/**711* Given a choice from the API call, returns the reason for filtering out the choice, or undefined if the choice should not be filtered out.712* @param choice The choice from the API call713* @returns The reason for filtering out the choice, or undefined if the choice should not be filtered out.714*/715function choiceToFilterReason(choice: ExtendedChoiceJSON): FilterReason | undefined {716if (choice.finish_reason !== FinishedCompletionReason.ContentFilter) {717return undefined;718}719720if (choice.delta?.copilot_annotations?.TextCopyright) {721return FilterReason.Copyright;722}723724if (choice.delta?.copilot_annotations?.Sexual || choice.delta?.copilot_annotations?.SexualPattern) {725return FilterReason.Sexual;726}727if (choice.delta?.copilot_annotations?.Violence) {728return FilterReason.Violence;729}730731if (choice.delta?.copilot_annotations?.HateSpeech || choice.delta?.copilot_annotations?.HateSpeechPattern) {732return FilterReason.Hate;733}734735if (choice.delta?.copilot_annotations?.SelfHarm) {736return FilterReason.SelfHarm;737}738739if (choice.delta?.copilot_annotations?.PromptPromBlockList) {740return FilterReason.Prompt;741}742743if (!choice.content_filter_results) {744return undefined;745}746747for (const filter of Object.keys(choice.content_filter_results) as Exclude<FilterReason, FilterReason.Copyright>[]) {748if (choice.content_filter_results[filter]?.filtered) {749return filter;750}751}752return undefined;753}754755export function sendCommunicationErrorTelemetry(telemetryService: ITelemetryService, message: string, extra?: any) {756const args = [message, extra];757const secureMessage = (args.length > 0 ? JSON.stringify(args) : 'no msg');758759const enhancedData = TelemetryData.createAndMarkAsIssued({760context: 'fetch',761level: LogLevel[LogLevel.Error],762message: secureMessage,763});764765// send full content to secure telemetry766telemetryService.sendEnhancedGHTelemetryErrorEvent('log', enhancedData.properties, enhancedData.measurements);767768const data = TelemetryData.createAndMarkAsIssued({769context: 'fetch',770level: LogLevel[LogLevel.Error],771message: '[redacted]',772});773774// send content that excludes customer data to standard telemetry775telemetryService.sendGHTelemetryErrorEvent(776'log',777data.properties,778data.measurements779);780}781782783