/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as l10n from '@vscode/l10n'; import { Raw } from '@vscode/prompt-tsx'; import type { OpenAI } from 'openai'; import { Response } from '../../../platform/networking/common/fetcherService'; import { coalesce } from '../../../util/vs/base/common/arrays'; import { AsyncIterableObject } from '../../../util/vs/base/common/async'; import { binaryIndexOf } from '../../../util/vs/base/common/buffer'; import { Lazy } from '../../../util/vs/base/common/lazy'; import { SSEParser } from '../../../util/vs/base/common/sseParser'; import { isDefined } from '../../../util/vs/base/common/types'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ChatLocation } from '../../chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { ILogService } from '../../log/common/logService'; import { CUSTOM_TOOL_SEARCH_NAME } from '../../networking/common/anthropic'; import { FinishedCallback, getRequestId, gitHubCopilotRequestTeProperty, IResponseDelta, OpenAiFunctionTool, OpenAiResponsesFunctionTool, OpenAiToolSearchTool } from '../../networking/common/fetch'; import { TelemetryMessage } from '../../networking/common/messageTelemetry'; import { IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody } from '../../networking/common/networking'; import { APIErrorResponse, ChatCompletion, FilterReason, FinishedCompletionReason, modelsWithoutResponsesContextManagement, openAIContextManagementCompactionType, OpenAIContextManagementResponse, rawMessageToCAPI, TokenLogProb } from '../../networking/common/openai'; import { IToolDeferralService } from '../../networking/common/toolDeferralService'; import { sendEngineMessagesTelemetry, sendResponsesApiCompactionTelemetry } from '../../networking/node/chatStream'; import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManager'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; import { getVerbosityForModelSync, modelSupportCacheBreakPoints } from '../common/chatModelCapabilities'; import { rawPartAsCompactionData } from '../common/compactionDataContainer'; import { rawPartAsPhaseData } from '../common/phaseDataContainer'; import { getIndexOfStatefulMarker, getStatefulMarkerAndIndex, MISSING_STATEFUL_TOOL_RESULT } from '../common/statefulMarkerContainer'; import { rawPartAsThinkingEnvelope } from '../common/thinkingDataContainer'; import { createResponsesStreamDumper } from './responsesApiDebugDump'; export function getResponsesApiCompactionThreshold(configService: IConfigurationService, expService: IExperimentationService, endpoint: IChatEndpoint): number | undefined { const contextManagementEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiContextManagementEnabled, expService) && !modelsWithoutResponsesContextManagement.has(endpoint.family); if (!contextManagementEnabled) { return undefined; } return endpoint.modelMaxPromptTokens > 0 ? Math.floor(endpoint.modelMaxPromptTokens * 0.9) : 50000; } export function getVerbosityForModelSyncBasedOnExp(configService: IConfigurationService, expService: IExperimentationService, endpoint: IChatEndpoint): 'low' | 'medium' | 'high' | undefined { return getVerbosityForModelSync(endpoint, configService.getExperimentBasedConfig(ConfigKey.EnableGpt56Verbosity, expService)); } export function createResponsesRequestBody(accessor: ServicesAccessor, options: ICreateEndpointBodyOptions, model: string, endpoint: IChatEndpoint): IEndpointBody { const configService = accessor.get(IConfigurationService); const expService = accessor.get(IExperimentationService); const verbosity = getVerbosityForModelSyncBasedOnExp(configService, expService, endpoint); const compactThreshold = getResponsesApiCompactionThreshold(configService, expService, endpoint); // compaction supported for all the models but works well for codex models and any future models after 5.3 const webSocketStatefulMarker = resolveWebSocketStatefulMarker(accessor, options, model); // When WebSocket is in use, always defer to the WebSocket marker (which may be // undefined if the connection is new or the summary state changed). Never fall // back to the HTTP marker lookup in that case. const ignoreStatefulMarker = !!options.ignoreStatefulMarker || !!options.useWebSocket; const modeChanged = !!options.modeChanged; // Tool search: when enabled, split tools into non-deferred (included in the request) and deferred // (excluded from the request entirely). Uses OpenAI's client-executed tool search protocol: we add // { type: 'tool_search', execution: 'client' }. The model emits tool_search_call, which we handle via // our ToolSearchTool embeddings search, then round-trip as tool_search_output in the next request. const toolSearchEnabled = !!endpoint.supportsToolSearch && !!options.requestOptions?.tools?.some(t => t.function.name === CUSTOM_TOOL_SEARCH_NAME); const isAllowedConversationAgent = options.location === ChatLocation.Agent || options.location === ChatLocation.MessagesProxy; const isSubagent = options.telemetryProperties?.subType?.startsWith('subagent') ?? false; const shouldDeferTools = toolSearchEnabled && isAllowedConversationAgent && !isSubagent; const toolDeferralService = shouldDeferTools ? accessor.get(IToolDeferralService) : undefined; type ResponsesFunctionTool = OpenAI.Responses.FunctionTool & OpenAiResponsesFunctionTool; const functionTools: ResponsesFunctionTool[] = []; if (options.requestOptions?.tools) { for (const tool of options.requestOptions.tools) { if (!tool.function.name || tool.function.name.length === 0) { continue; } // Always skip the tool_search function tool — 'tool_search' is a reserved namespace in the // Responses API. Client-executed tool search uses { type: 'tool_search', execution: 'client' } instead. if (tool.function.name === CUSTOM_TOOL_SEARCH_NAME) { continue; } const isDeferred = shouldDeferTools && !toolDeferralService!.isNonDeferredTool(tool.function.name); // Client-executed tool search: deferred tools are NOT sent in the request. // They are returned via tool_search_output when the model searches for them. if (isDeferred) { continue; } functionTools.push({ ...tool.function, type: 'function', strict: false, parameters: (tool.function.parameters || { type: 'object', properties: {} }) as Record, }); } } // Build final tools array const finalTools: Array = [...functionTools]; if (shouldDeferTools) { // Client-executed tool search: the model emits tool_search_call, our ToolSearchTool // handles the embeddings search, and we return tool_search_output with full definitions. finalTools.unshift({ type: 'tool_search', execution: 'client', description: 'Search for relevant tools by describing what you need. Returns tool definitions for tools matching your query.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'Natural language description of what tool capability you are looking for.', }, }, required: ['query'], }, } as ClientToolSearchTool); } const toolsMap = options.requestOptions?.tools ? new Map(options.requestOptions.tools.map(t => [t.function.name, t])) : undefined; const shouldLoadToolFromToolSearch = shouldDeferTools ? (name: string) => !toolDeferralService!.isNonDeferredTool(name) : undefined; // Opt-in endpoints (client-side BYOK) ignore the setting's default and experiment treatments: // `isConfigured` is only true when the user has set the value in their settings. const promptCacheBreakpointsEnabled = endpoint.promptCacheBreakpointsRequireOptIn ? configService.isConfigured(ConfigKey.ResponsesApiPromptCacheBreakpointEnabled) && configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPromptCacheBreakpointEnabled, expService) : configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPromptCacheBreakpointEnabled, expService); const modelSupportsCacheBreakpoints = modelSupportCacheBreakPoints(endpoint); const supportsCacheBreakpoints = promptCacheBreakpointsEnabled && modelSupportsCacheBreakpoints; const body: IEndpointBody = { model, ...rawMessagesToResponseAPI(model, options.messages, ignoreStatefulMarker, webSocketStatefulMarker, { toolsMap, shouldLoadToolFromToolSearch, modeChanged, supportsCacheBreakpoints, }), stream: true, tools: finalTools.length > 0 ? finalTools : undefined, // Only a subset of completion post options are supported, and some // are renamed. Handle them manually: max_output_tokens: options.postOptions.max_tokens, tool_choice: typeof options.postOptions.tool_choice === 'object' ? { type: 'function', name: options.postOptions.tool_choice.function.name } : options.postOptions.tool_choice, top_logprobs: options.postOptions.logprobs ? 3 : undefined, store: false, text: verbosity ? { verbosity } : undefined, prompt_cache_options: modelSupportsCacheBreakpoints ? { mode: supportsCacheBreakpoints ? 'explicit' : 'implicit' } : undefined, }; if (compactThreshold !== undefined) { body.context_management = [{ 'type': openAIContextManagementCompactionType, // Trigger compaction at 90% of the model max prompt context to keep headroom for active turns. 'compact_threshold': compactThreshold }]; } body.truncation = configService.getConfig(ConfigKey.Advanced.UseResponsesApiTruncation) ? 'auto' : 'disabled'; const effortFromSetting = configService.getConfig(ConfigKey.Advanced.ReasoningEffortOverride); const effort = endpoint.supportsReasoningEffort?.length ? (effortFromSetting || options.modelCapabilities?.reasoningEffort || 'medium') : undefined; const summary: string | undefined = undefined; if (effort || summary) { body.reasoning = { ...(effort ? { effort } : {}), ...(summary ? { summary } : {}) }; } body.include = ['reasoning.encrypted_content']; const promptCacheKeyEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPromptCacheKeyEnabled, expService); if (promptCacheKeyEnabled && options.conversationId) { body.prompt_cache_key = `${options.conversationId}:${endpoint.family}`; } return body; } export function getResponsesApiCompactionThresholdFromBody(body: Pick): number | undefined { const contextManagement = body.context_management; if (!Array.isArray(contextManagement)) { return undefined; } for (const item of contextManagement) { if (item.type === openAIContextManagementCompactionType && typeof item.compact_threshold === 'number') { return item.compact_threshold; } } return undefined; } interface ResponseInputAssistantTextContentPart { type: 'output_text'; text: string; } interface ResponseInputAssistantMessageWithPhase { type: 'message'; role: 'assistant'; content: ResponseInputAssistantTextContentPart[]; phase?: string; } interface ResponseOutputItemWithPhase { phase?: string; } // ── Responses API tool search types ────────────────────────────────── // These match the shapes from https://developers.openai.com/api/docs/guides/tools-tool-search /** Client-executed tool_search tool definition for the Responses API */ interface ClientToolSearchTool { type: 'tool_search'; execution: 'client'; description: string; parameters: Record; } interface ResponsesToolSearchCall { type: 'tool_search_call'; id: string; execution: 'client'; call_id: string | null; status: string; arguments?: Record; } /** Input item shape for a client-executed tool_search_call in conversation history */ interface ResponsesToolSearchCallInput { type: 'tool_search_call'; execution: 'client'; call_id: string; status: string; arguments: Record; } /** Input item shape for a client-executed tool_search_output in conversation history */ interface ResponsesToolSearchOutputInput { type: 'tool_search_output'; execution: 'client'; call_id: string; status: string; tools: ToolSearchLoadedTool[]; } /** A tool definition returned in tool_search_output */ interface ToolSearchLoadedTool { type: 'function'; name: string; description: string; defer_loading: true; parameters: object; strict: false; } interface LatestCompactionOutput { readonly item: OpenAIContextManagementResponse; readonly outputIndex: number; } type CompactionResponseOutputItem = OpenAI.Responses.ResponseOutputItem & OpenAIContextManagementResponse; interface CompactionItemInChunk { readonly item: OpenAIContextManagementResponse; readonly outputIndex: number | undefined; } interface ResponseStreamEventWithOutputItem { readonly item: unknown; readonly output_index: number; } interface ResponseStreamEventWithResponseOutput { readonly response: { readonly output: OpenAI.Responses.ResponseOutputItem[]; }; } function resolveWebSocketStatefulMarker(accessor: ServicesAccessor, options: ICreateEndpointBodyOptions, modelId: string): string | undefined { if (options.ignoreStatefulMarker || !options.useWebSocket || !options.conversationId) { return undefined; } const wsManager = accessor.get(IChatWebSocketManager); const connectionKey = { conversationId: options.conversationId, modelId, connectionId: options.webSocketConnectionId }; // If client-side summarization state changed since the stateful marker // was stored (new summary, or rollback removing a summary), the server's // state no longer matches. Skip the marker so the full history is sent. const connSummarizedAt = wsManager.getSummarizedAtRoundId(connectionKey); if (options.summarizedAtRoundId !== connSummarizedAt) { return undefined; } return wsManager.getStatefulMarker(connectionKey); } interface RawMessagesToResponseAPIOptions { readonly toolsMap?: Map; readonly shouldLoadToolFromToolSearch?: (name: string) => boolean; readonly modeChanged?: boolean; readonly supportsCacheBreakpoints?: boolean; } function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMessage[], ignoreStatefulMarker: boolean, webSocketStatefulMarker: string | undefined, options: RawMessagesToResponseAPIOptions = {}): { input: OpenAI.Responses.ResponseInputItem[]; previous_response_id?: string } { const { toolsMap, shouldLoadToolFromToolSearch, modeChanged = false, supportsCacheBreakpoints = false } = options; const latestCompactionMessageIndex = getLatestCompactionMessageIndex(messages); const latestCompactionMessage = latestCompactionMessageIndex !== undefined ? createCompactionRoundTripMessage(messages[latestCompactionMessageIndex]) : undefined; let previousResponseId: string | undefined; let markerIndex: number | undefined; if (webSocketStatefulMarker) { // WebSocket path: use the connection's current stateful marker if present in messages markerIndex = getIndexOfStatefulMarker(webSocketStatefulMarker, messages); if (markerIndex !== undefined) { previousResponseId = webSocketStatefulMarker; } } else if (!ignoreStatefulMarker) { // HTTP path: look up the latest marker for this model from messages const statefulMarkerAndIndex = getStatefulMarkerAndIndex(modelId, messages); if (statefulMarkerAndIndex) { previousResponseId = statefulMarkerAndIndex.statefulMarker; markerIndex = statefulMarkerAndIndex.index; } } if (modeChanged) { previousResponseId = undefined; markerIndex = undefined; } let statefulToolCalls: Array<{ id: string; name: string }> = []; if (markerIndex !== undefined) { const markerMessage = messages[markerIndex]; if (markerMessage.role === Raw.ChatRole.Assistant && markerMessage.toolCalls?.length) { statefulToolCalls = markerMessage.toolCalls.map(toolCall => ({ id: toolCall.id, name: toolCall.function.name })); } } const toolSearchCallIds = new Set(); const toolSearchLoadedTools = new Set(); // Only pre-scan when history will be sliced (matches the slicing block below); // otherwise the serialization loop visits each tool_search_call before its // result and populates these sets in order on its own. const willSliceHistory = markerIndex !== undefined || latestCompactionMessageIndex !== undefined; if (willSliceHistory) { for (const message of messages) { if (message.role === Raw.ChatRole.Assistant && message.toolCalls) { for (const toolCall of message.toolCalls) { if (toolCall.function.name === CUSTOM_TOOL_SEARCH_NAME) { toolSearchCallIds.add(toolCall.id); } } } else if (message.role === Raw.ChatRole.Tool && message.toolCallId && toolSearchCallIds.has(message.toolCallId) && toolsMap) { const resultText = message.content .filter(c => c.type === Raw.ChatCompletionContentPartKind.Text) .map(c => c.text) .join(''); for (const t of buildToolSearchOutputTools(resultText, toolsMap, shouldLoadToolFromToolSearch)) { toolSearchLoadedTools.add(t.name); } } } } if (markerIndex !== undefined) { // Requests that resume from previous_response_id send only post-marker history, // but they still need the latest compaction item even when that item predates // the marker. This keeps both websocket and non-websocket traffic aligned. messages = messages.slice(markerIndex + 1); if (latestCompactionMessageIndex !== undefined) { if (latestCompactionMessageIndex > markerIndex) { messages = messages.slice(latestCompactionMessageIndex - (markerIndex + 1)); } else if (latestCompactionMessage) { messages = [latestCompactionMessage, ...messages]; } } } else if (latestCompactionMessageIndex !== undefined) { messages = messages.slice(latestCompactionMessageIndex); } // The server retains calls from previous_response_id even when prompt pruning removes // their local results. Close every call absent from the final post-marker message slice. const sentToolResultIds = new Set(messages .filter((message): message is Raw.ToolChatMessage => message.role === Raw.ChatRole.Tool) .map(message => message.toolCallId)); statefulToolCalls = statefulToolCalls.filter(toolCall => !sentToolResultIds.has(toolCall.id)); const input: OpenAI.Responses.ResponseInputItem[] = []; for (const toolCall of statefulToolCalls) { if (toolCall.name === CUSTOM_TOOL_SEARCH_NAME) { input.push({ type: 'tool_search_output', execution: 'client', call_id: toolCall.id, status: 'completed', tools: [], } satisfies ResponsesToolSearchOutputInput as unknown as OpenAI.Responses.ResponseInputItem); } else { input.push({ type: 'function_call_output', call_id: toolCall.id, output: supportsCacheBreakpoints ? [{ type: 'input_text', text: MISSING_STATEFUL_TOOL_RESULT }] : MISSING_STATEFUL_TOOL_RESULT, }); } } for (const message of messages) { switch (message.role) { case Raw.ChatRole.Assistant: if (message.content.length) { input.push(...extractCompactionData(message.content)); input.push(...extractThinkingData(message.content)); const asstContent = message.content.map(rawContentToResponsesAssistantContent).filter(isDefined); if (asstContent.length) { const assistantMessage: ResponseInputAssistantMessageWithPhase = { role: 'assistant', content: asstContent, type: 'message', phase: extractPhaseData(message.content), }; // The Responses API expects previous assistant message content as output_text/refusal, // but the SDK's ResponseOutputMessage type requires response-only id/status fields. input.push(assistantMessage as OpenAI.Responses.ResponseInputItem); } } if (message.toolCalls) { for (const toolCall of message.toolCalls) { if (toolCall.function.name === CUSTOM_TOOL_SEARCH_NAME) { // Client-executed tool search: emit as tool_search_call instead of function_call toolSearchCallIds.add(toolCall.id); let parsedArgs: Record = {}; try { parsedArgs = JSON.parse(toolCall.function.arguments || '{}'); } catch { } input.push({ type: 'tool_search_call', execution: 'client', call_id: toolCall.id, status: 'completed', arguments: parsedArgs, } satisfies ResponsesToolSearchCallInput as unknown as OpenAI.Responses.ResponseInputItem); } else { // Tools loaded via tool_search need a namespace field to round-trip correctly const namespace = toolSearchLoadedTools.has(toolCall.function.name) ? toolCall.function.name : undefined; input.push({ type: 'function_call', name: toolCall.function.name, arguments: toolCall.function.arguments, call_id: toolCall.id, ...(namespace ? { namespace } : {}) }); } } } break; case Raw.ChatRole.Tool: if (message.toolCallId) { if (toolSearchCallIds.has(message.toolCallId)) { // Client-executed tool search result: convert tool names to tool_search_output with full definitions const resultText = message.content .filter(c => c.type === Raw.ChatCompletionContentPartKind.Text) .map(c => c.text) .join(''); const loadedTools = toolsMap ? buildToolSearchOutputTools(resultText, toolsMap, shouldLoadToolFromToolSearch) : []; for (const t of loadedTools) { toolSearchLoadedTools.add(t.name); } input.push({ type: 'tool_search_output', execution: 'client', call_id: message.toolCallId, status: 'completed', tools: loadedTools, } satisfies ResponsesToolSearchOutputInput as unknown as OpenAI.Responses.ResponseInputItem); } else { if (supportsCacheBreakpoints) { input.push({ type: 'function_call_output', call_id: message.toolCallId, output: rawContentToResponsesContentList(message.content, true), }); break; } const asText = message.content .filter(c => c.type === Raw.ChatCompletionContentPartKind.Text) .map(c => c.text) .join(''); const asImages = message.content .filter(c => c.type === Raw.ChatCompletionContentPartKind.Image) .map((c): OpenAI.Responses.ResponseInputImage => ({ type: 'input_image', detail: c.imageUrl.detail || 'auto', image_url: c.imageUrl.url, })); const asFiles = message.content .filter((c): c is RawDocumentContentPart => c.type === Raw.ChatCompletionContentPartKind.Document) .map(rawDocumentToResponsesInputFile) .filter(isDefined); // Preserve the legacy string output and synthetic media messages unless explicit // prompt cache breakpoints are both enabled and supported by the model. input.push({ type: 'function_call_output', call_id: message.toolCallId, output: asText }); if (asImages.length) { input.push({ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Image associated with the above tool call:' }, ...asImages] }); } if (asFiles.length) { input.push({ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'PDF associated with the above tool call:' }, ...asFiles] }); } } } break; case Raw.ChatRole.User: input.push({ type: 'message', role: 'user', content: rawContentToResponsesContentList(message.content, supportsCacheBreakpoints) }); break; case Raw.ChatRole.System: input.push({ type: 'message', role: 'system', content: rawContentToResponsesContentList(message.content, supportsCacheBreakpoints) }); break; } } return { input, previous_response_id: previousResponseId }; } /** * Converts a JSON array of tool names (from ToolSearchTool) into full tool definitions * for the tool_search_output. Falls back to an empty array on parse failure. */ function buildToolSearchOutputTools(resultText: string, toolsMap: Map, shouldLoadToolFromToolSearch: ((name: string) => boolean) | undefined): ToolSearchLoadedTool[] { let toolNames: unknown; try { toolNames = JSON.parse(resultText); } catch { return []; } if (!Array.isArray(toolNames)) { return []; } return toolNames .filter((name): name is string => typeof name === 'string' && name !== CUSTOM_TOOL_SEARCH_NAME && toolsMap.has(name) && shouldLoadToolFromToolSearch?.(name) === true) .map(name => { const tool = toolsMap.get(name)!; return { type: 'function' as const, name: tool.function.name, description: tool.function.description || '', defer_loading: true as const, parameters: tool.function.parameters || { type: 'object', properties: {} }, strict: false as const, }; }); } function createCompactionRoundTripMessage(message: Raw.ChatMessage): Raw.ChatMessage | undefined { if (message.role !== Raw.ChatRole.Assistant) { return undefined; } const content = message.content.filter(part => part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsCompactionData(part)); if (!content.length) { return undefined; } return { role: Raw.ChatRole.Assistant, content, }; } function getLatestCompactionMessageIndex(messages: readonly Raw.ChatMessage[]): number | undefined { for (let idx = messages.length - 1; idx >= 0; idx--) { const message = messages[idx]; for (const part of message.content) { if (part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsCompactionData(part)) { return idx; } } } return undefined; } type RawDocumentContentPart = Extract; function rawDocumentToResponsesInputFile(part: RawDocumentContentPart): OpenAI.Responses.ResponseInputFile | undefined { if (part.documentData.mediaType !== 'application/pdf') { return undefined; } return { type: 'input_file', filename: 'document.pdf', file_data: `data:${part.documentData.mediaType};base64,${part.documentData.data}`, }; } type ResponsesConvertibleContent = OpenAI.Responses.ResponseInputText | OpenAI.Responses.ResponseInputImage | OpenAI.Responses.ResponseInputFile; function rawContentToResponsesContent(part: Raw.ChatCompletionContentPart): ResponsesConvertibleContent | undefined { switch (part.type) { case Raw.ChatCompletionContentPartKind.Text: return { type: 'input_text', text: part.text }; case Raw.ChatCompletionContentPartKind.Image: return { type: 'input_image', detail: part.imageUrl.detail || 'auto', image_url: part.imageUrl.url }; case Raw.ChatCompletionContentPartKind.Document: return rawDocumentToResponsesInputFile(part); case Raw.ChatCompletionContentPartKind.Opaque: { const maybeCast = part.value as ResponsesConvertibleContent; if (maybeCast.type === 'input_text' || maybeCast.type === 'input_image' || maybeCast.type === 'input_file') { return { ...maybeCast }; } } } } function rawContentToResponsesAssistantContent(part: Raw.ChatCompletionContentPart): Pick | undefined { switch (part.type) { case Raw.ChatCompletionContentPartKind.Text: if (part.text.trim()) { return { type: 'output_text', text: part.text }; } } } interface ResponsesPromptCacheBreakpoint { readonly mode: 'explicit'; } type ResponsesCacheableContent = ResponsesConvertibleContent & { prompt_cache_breakpoint?: ResponsesPromptCacheBreakpoint; }; const promptCacheBreakpoint: ResponsesPromptCacheBreakpoint = { mode: 'explicit' }; function rawContentToResponsesContentList(parts: readonly Raw.ChatCompletionContentPart[], supportsCacheBreakpoints: boolean): ResponsesConvertibleContent[] { const content: ResponsesCacheableContent[] = []; let target: ResponsesCacheableContent | undefined; for (const part of parts) { if (part.type === Raw.ChatCompletionContentPartKind.CacheBreakpoint) { if (supportsCacheBreakpoints && target) { target.prompt_cache_breakpoint = promptCacheBreakpoint; } continue; } const converted = rawContentToResponsesContent(part); if (converted) { target = converted; content.push(target); } else { target = undefined; } } return content; } function extractThinkingData(content: Raw.ChatCompletionContentPart[]): OpenAI.Responses.ResponseReasoningItem[] { return coalesce(content.map(part => { if (part.type === Raw.ChatCompletionContentPartKind.Opaque) { const envelope = rawPartAsThinkingEnvelope(part); // Preserve legacy replay behavior for history and extensions that do not supply an API type. const isResponsesReasoning = envelope?.originApi === undefined ? typeof envelope?.thinking.id === 'string' && envelope.thinking.id.startsWith('rs') : envelope.originApi === 'responses'; if (isResponsesReasoning && envelope?.thinking.encrypted) { return { type: 'reasoning', id: envelope.thinking.id, summary: [], encrypted_content: envelope.thinking.encrypted, } satisfies OpenAI.Responses.ResponseReasoningItem; } } })); } function extractPhaseData(content: Raw.ChatCompletionContentPart[]): string | undefined { for (const part of content) { if (part.type === Raw.ChatCompletionContentPartKind.Opaque) { const phase = rawPartAsPhaseData(part); if (phase) { return phase; } } } return undefined; } /** * Extracts compaction data from opaque content parts and converts them to * Responses API input items for round-tripping. */ function extractCompactionData(content: Raw.ChatCompletionContentPart[]): OpenAI.Responses.ResponseInputItem[] { return coalesce(content.map(part => { if (part.type === Raw.ChatCompletionContentPartKind.Opaque) { const compaction = rawPartAsCompactionData(part); if (compaction) { return { type: openAIContextManagementCompactionType, id: compaction.id, encrypted_content: compaction.encrypted_content, } as unknown as OpenAI.Responses.ResponseInputItem; } } })); } /** * This is an approximate responses input -> raw messages helper, should be used for logging only */ export function responseApiInputToRawMessagesForLogging(body: OpenAI.Responses.ResponseCreateParams): Raw.ChatMessage[] { return convertResponseApiInputForLogging(body, (message, item) => isCompactionItem(item) || item?.type === 'image_generation_call' ? undefined : message); } /** * Retains native reasoning fields for telemetry instead of the human-readable placeholder used * by debug logging. The model request and the raw messages returned to callers remain unchanged. */ export function responseApiInputToTelemetryMessages(body: OpenAI.Responses.ResponseCreateParams): TelemetryMessage[] { return convertResponseApiInputForLogging(body, (message, item) => { if (item?.type === 'reasoning' || isCompactionItem(item)) { return { ...item, role: 'assistant' }; } if (item?.type === 'image_generation_call' && item.result) { return { role: 'assistant', content: [{ type: 'image_url', image_url: { url: item.result } }] }; } if (item && isResponseOutputMessage(item)) { message = { role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: item.content.map(part => part.type === 'output_text' ? part.text : part.refusal).join(''), }], }; } return { ...rawMessageToCAPI(message), ...(item && 'phase' in item && typeof item.phase === 'string' ? { phase: item.phase } : {}), }; }); } function convertResponseApiInputForLogging(body: OpenAI.Responses.ResponseCreateParams, convert: (message: Raw.ChatMessage, item?: OpenAI.Responses.ResponseInputItem) => T | undefined): T[] { const messages: T[] = []; const append = (message: Raw.ChatMessage, item?: OpenAI.Responses.ResponseInputItem) => { const converted = convert(message, item); if (converted !== undefined) { messages.push(converted); } }; const pendingFunctionCalls: Raw.ChatMessageToolCall[] = []; const flushPendingFunctionCalls = () => { if (pendingFunctionCalls.length > 0) { append({ role: Raw.ChatRole.Assistant, content: [], toolCalls: pendingFunctionCalls.splice(0) }); } }; // Add system instructions if provided if (body.instructions) { append({ role: Raw.ChatRole.System, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: body.instructions }] }); } // Convert input to array format if it's a string const inputItems = typeof body.input === 'string' ? [{ role: 'user' as const, content: body.input, type: 'message' as const }] : (body.input ?? []); for (const item of inputItems) { // Handle message items with roles if ('role' in item) { switch (item.role) { case 'user': flushPendingFunctionCalls(); append({ role: Raw.ChatRole.User, content: ensureContentArray(item.content).map(responseContentToRawContent).filter(isDefined) }, item); break; case 'system': case 'developer': flushPendingFunctionCalls(); append({ role: Raw.ChatRole.System, content: ensureContentArray(item.content).map(responseContentToRawContent).filter(isDefined) }, item); break; case 'assistant': flushPendingFunctionCalls(); if (isResponseOutputMessage(item)) { append({ role: Raw.ChatRole.Assistant, content: item.content.map(responseOutputToRawContent).filter(isDefined) }, item); } else if (isResponseInputItemMessage(item)) { append({ role: Raw.ChatRole.Assistant, content: ensureContentArray(item.content).map(responseContentToRawContent).filter(isDefined) }, item); } break; } } else if ('type' in item) { // Handle other item types without roles switch (item.type) { case 'function_call': // Collect function calls to be grouped with the next assistant message pendingFunctionCalls.push({ id: item.call_id, type: 'function', function: { name: item.name, arguments: item.arguments } }); break; case 'function_call_output': { flushPendingFunctionCalls(); const content = responseFunctionOutputToRawContents(item.output); append({ role: Raw.ChatRole.Tool, content, toolCallId: item.call_id }, item); break; } case 'reasoning': // We can't perfectly reconstruct the original thinking data // but we can add a placeholder for logging flushPendingFunctionCalls(); append({ role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: `Reasoning summary: ${item.summary.map(s => s.text).join('\n\n')}` }] }, item); break; default: { if (item.type === 'image_generation_call' && item.result) { const converted = convert({ role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: item.result } }], }, item); if (converted !== undefined) { flushPendingFunctionCalls(); messages.push(converted); } break; } if (isCompactionItem(item)) { const converted = convert({ role: Raw.ChatRole.Assistant, content: [] }, item); if (converted !== undefined) { flushPendingFunctionCalls(); messages.push(converted); } break; } // Client-executed tool search items (tool_search_call / tool_search_output) const tsItem = item as unknown as ResponsesToolSearchCallInput | ResponsesToolSearchOutputInput; if (tsItem.type === 'tool_search_call') { pendingFunctionCalls.push({ id: tsItem.call_id, type: 'function', function: { name: CUSTOM_TOOL_SEARCH_NAME, arguments: JSON.stringify(tsItem.arguments ?? {}), } }); } else if (tsItem.type === 'tool_search_output') { flushPendingFunctionCalls(); const toolNames = tsItem.tools.map(t => t.name); append({ role: Raw.ChatRole.Tool, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: JSON.stringify(toolNames), }], toolCallId: tsItem.call_id, }); } break; } } } } // Flush any remaining function calls at the end if (pendingFunctionCalls.length > 0) { append({ role: Raw.ChatRole.Assistant, content: [], toolCalls: pendingFunctionCalls.splice(0) }); } return messages; } function isResponseOutputMessage(item: OpenAI.Responses.ResponseInputItem): item is OpenAI.Responses.ResponseOutputMessage { return 'role' in item && item.role === 'assistant' && 'type' in item && item.type === 'message' && 'content' in item && Array.isArray(item.content); } function isResponseInputItemMessage(item: OpenAI.Responses.ResponseInputItem): item is OpenAI.Responses.ResponseInputItem.Message { return 'role' in item && item.role === 'assistant' && (!('type' in item) || item.type !== 'message'); } function ensureContentArray(content: string | OpenAI.Responses.ResponseInputMessageContentList): OpenAI.Responses.ResponseInputMessageContentList { if (typeof content === 'string') { return [{ type: 'input_text', text: content }]; } return content; } function responseContentToRawContent(part: OpenAI.Responses.ResponseInputContent | OpenAI.Responses.ResponseFunctionCallOutputItem): Raw.ChatCompletionContentPart | undefined { switch (part.type) { case 'input_text': return { type: Raw.ChatCompletionContentPartKind.Text, text: part.text }; case 'input_image': return { type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: part.image_url || '', detail: part.detail === 'auto' ? undefined : (part.detail ?? undefined) } }; case 'input_file': // This is a rough approximation for logging return { type: Raw.ChatCompletionContentPartKind.Opaque, value: `[File Input - Filename: ${part.filename || 'unknown'}]` }; } } function responseOutputToRawContent(part: OpenAI.Responses.ResponseOutputText | OpenAI.Responses.ResponseOutputRefusal): Raw.ChatCompletionContentPart | undefined { switch (part.type) { case 'output_text': return { type: Raw.ChatCompletionContentPartKind.Text, text: part.text }; case 'refusal': return { type: Raw.ChatCompletionContentPartKind.Text, text: `[Refusal: ${part.refusal}]` }; } } function responseFunctionOutputToRawContents(output: string | OpenAI.Responses.ResponseFunctionCallOutputItemList): Raw.ChatCompletionContentPart[] { if (typeof output === 'string') { return [{ type: Raw.ChatCompletionContentPartKind.Text, text: output }]; } return coalesce(output.map(responseContentToRawContent)); } function isCompactionItem(value: unknown): value is OpenAIContextManagementResponse { return typeof value === 'object' && value !== null && 'type' in value && String(value.type) === openAIContextManagementCompactionType; } function hasOutputItem(chunk: OpenAI.Responses.ResponseStreamEvent): chunk is OpenAI.Responses.ResponseStreamEvent & ResponseStreamEventWithOutputItem { return 'item' in chunk && 'output_index' in chunk && typeof chunk.output_index === 'number'; } function hasResponseOutput(chunk: OpenAI.Responses.ResponseStreamEvent): chunk is OpenAI.Responses.ResponseStreamEvent & ResponseStreamEventWithResponseOutput { return 'response' in chunk && Array.isArray(chunk.response.output); } function getOutputItemIndex(chunk: ResponseStreamEventWithOutputItem): number { return chunk.output_index; } function isCompactionOutputItem(item: OpenAI.Responses.ResponseOutputItem): item is CompactionResponseOutputItem { return isCompactionItem(item); } function getLatestCompactionOutput(output: OpenAI.Responses.ResponseOutputItem[], preferredOutputIndex: number | undefined): LatestCompactionOutput | undefined { let latestCompactionOutput: LatestCompactionOutput | undefined; for (let idx = output.length - 1; idx >= 0; idx--) { const item = output[idx]; if (isCompactionOutputItem(item)) { latestCompactionOutput = { item, outputIndex: idx }; break; } } if (preferredOutputIndex !== undefined) { const preferredItem = output[preferredOutputIndex]; if (preferredItem && isCompactionOutputItem(preferredItem) && (!latestCompactionOutput || preferredOutputIndex >= latestCompactionOutput.outputIndex)) { return { item: preferredItem, outputIndex: preferredOutputIndex }; } } return latestCompactionOutput; } function keepLatestCompactionOutput(output: OpenAI.Responses.ResponseOutputItem[], preferredOutputIndex: number | undefined): OpenAI.Responses.ResponseOutputItem[] { const latestCompactionOutput = getLatestCompactionOutput(output, preferredOutputIndex); if (!latestCompactionOutput) { return output; } return output.filter((item, idx) => !isCompactionOutputItem(item) || idx === latestCompactionOutput.outputIndex); } export async function processResponseFromChatEndpoint(instantiationService: IInstantiationService, telemetryService: ITelemetryService, logService: ILogService, response: Response, expectedNumChoices: number, finishCallback: FinishedCallback, telemetryData: TelemetryData, compactionThreshold?: number, canResumeResponses: (responseId: string) => boolean = () => true): Promise> { return new AsyncIterableObject(async feed => { const requestId = response.headers.get('X-Request-ID') ?? generateUuid(); const ghRequestId = response.headers.get('x-github-request-id') ?? ''; const { serverExperiments, copilotServiceRequestId, gitHubCopilotRequestTe } = getRequestId(response.headers); const processor = instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, requestId, ghRequestId, copilotServiceRequestId, serverExperiments, compactionThreshold); processor.gitHubCopilotRequestTe = gitHubCopilotRequestTe; const dumper = createResponsesStreamDumper(requestId, logService); const parser = new SSEParser((ev) => { try { logService.trace(`SSE: ${ev.data}`); if (ev.data === '[DONE]') { // Some OpenAI-compatible gateways (e.g. LiteLLM) emit the chat-completions // `[DONE]` sentinel at the end of a Responses stream. Ignore it. return; } const parsedData = JSON.parse(ev.data); const responseStreamEvent: OpenAI.Responses.ResponseStreamEvent = { type: ev.type, ...parsedData }; dumper.logEvent(responseStreamEvent); const completion = processor.push(responseStreamEvent, finishCallback, canResumeResponses); if (completion) { sendCompletionOutputTelemetry(telemetryService, logService, completion, telemetryData); feed.emitOne(completion); } } catch (e) { feed.reject(e); } }); for await (const chunk of response.body) { parser.feed(chunk); } }, async () => { await response.body.destroy(); }); } export function sendCompletionOutputTelemetry(telemetryService: ITelemetryService, logService: ILogService, completion: ChatCompletion, telemetryData: TelemetryData): void { const telemetryMessages = completion.telemetryMessages ?? [rawMessageToCAPI(completion.message)]; let telemetryDataWithUsage = telemetryData.extendedBy(gitHubCopilotRequestTeProperty(completion.requestId.gitHubCopilotRequestTe)); if (completion.usage) { telemetryDataWithUsage = telemetryDataWithUsage.extendedBy({}, { promptTokens: completion.usage.prompt_tokens, completionTokens: completion.usage.completion_tokens, totalTokens: completion.usage.total_tokens, ...(completion.usage.prompt_tokens_details && { cachedTokens: completion.usage.prompt_tokens_details.cached_tokens }), ...(completion.usage.completion_tokens_details && { reasoningTokens: completion.usage.completion_tokens_details.reasoning_tokens, acceptedPredictionTokens: completion.usage.completion_tokens_details.accepted_prediction_tokens, rejectedPredictionTokens: completion.usage.completion_tokens_details.rejected_prediction_tokens, }), }); } sendEngineMessagesTelemetry(telemetryService, telemetryMessages, telemetryDataWithUsage, true, logService); } interface CapiResponsesTextDeltaEvent extends Omit { logprobs: Array | undefined; } interface CapiResponseCompletedEvent extends OpenAI.Responses.ResponseCompletedEvent { copilot_usage?: { total_nano_aiu: number; }; } /** * Terminal Responses-API events (`response.completed`, `response.incomplete`, * `response.failed`). CAPI extends the standard payload with a `content_filters` * array that carries the actual block reason when a response is cut short by a * content filter. The OpenAI types don't include this field, so we narrow with * a local interface. * * Two shapes are observed on the wire and we handle both: * - `content_filter_results` is the per-category structured map defined by the * Azure REST spec ({@link https://learn.microsoft.com/azure/ai-services/openai/concepts/content-filter | docs}); * e.g. `{ hate: { filtered: true, severity: 'high' }, protected_material_text: { filtered: true } }`. * - `content_filter_raw` is a CAPI-internal/legacy passthrough that carries the * raw RAI rule decisions (`{ action: 'BLOCK', label: 'TextCopyright', result: true }`) * and is not in the Azure spec. It's currently what production emits, so we * match it first and only fall back to the structured map. */ interface CapiResponseTerminalEvent { response: OpenAI.Responses.Response & { content_filters?: CapiContentFilterEntry[] | null; }; } interface CapiContentFilterEntry { source_type?: 'prompt' | 'completion' | string; blocked?: boolean; content_filter_raw?: Array<{ action?: string; label?: string; result?: unknown }>; content_filter_results?: Record; } /** * Map CAPI's `content_filters` (sent on a terminal Responses event when a * response is blocked) to a {@link FilterReason}. Returns `undefined` if no * reason can be deduced; the caller defaults to {@link FilterReason.Copyright}. */ function extractFilterReasonFromContentFilters(filters: CapiContentFilterEntry[] | null | undefined): FilterReason | undefined { if (!filters) { return undefined; } // Prefer a completion-side block; if none, fall back to a prompt-side block. const blocked = filters.filter(f => f.blocked); const completion = blocked.find(f => f.source_type === 'completion') ?? blocked[0]; if (!completion) { return undefined; } // Look for a definitive BLOCK action in content_filter_raw. The label // `TextCopyright` maps to our Copyright filter; the multi-severity labels // map to hate/self-harm/sexual/violence. const blockingRule = completion.content_filter_raw?.find(r => r.action === 'BLOCK' && r.result === true); const label = blockingRule?.label?.toLowerCase() ?? ''; if (label.includes('copyright')) { return FilterReason.Copyright; } if (label.includes('selfharm') || label.includes('self_harm')) { return FilterReason.SelfHarm; } if (label.includes('sexual')) { return FilterReason.Sexual; } if (label.includes('violence')) { return FilterReason.Violence; } if (label.includes('hate')) { return FilterReason.Hate; } // Fall back to the Azure-spec'd per-category result map (`AzureContentFilterResultsForResponsesAPI`). const results = completion.content_filter_results ?? {}; if (results.hate?.filtered) { return FilterReason.Hate; } if (results.self_harm?.filtered) { return FilterReason.SelfHarm; } if (results.sexual?.filtered) { return FilterReason.Sexual; } if (results.violence?.filtered) { return FilterReason.Violence; } if (results.protected_material_text?.filtered || results.protected_material_code?.filtered) { return FilterReason.Copyright; } if (completion.source_type === 'prompt') { return FilterReason.Prompt; } return undefined; } /** * Identifying details for the terminal Responses event that carried an error. * Used to describe failures whose error object omits the fields the API * contract requires. */ interface IResponsesErrorContext { /** The terminal SSE event that carried the error, e.g. `response.failed`. */ readonly eventType: string; /** `id` from the response envelope, so a user report stays correlatable upstream. */ readonly responseId?: string; /** `status` from the response envelope, when present. */ readonly responseStatus?: string; } function toResponsesErrorContext(eventType: string, response?: Pick): IResponsesErrorContext { return { eventType, responseId: response?.id || undefined, responseStatus: response?.status || undefined, }; } /** * Describe a terminal error that carries no usable message. Names only the * event, status, response id, and provider code so the failure stays * diagnosable and correlatable without exposing prompt content. */ function describeUninformativeResponsesError(code: string | undefined, context: IResponsesErrorContext): string { // Structured diagnostic identifiers, kept verbatim so they stay greppable and // pasteable into a provider support request. const details = [ `event: ${context.eventType}`, ...(context.responseStatus ? [`status: ${context.responseStatus}`] : []), ...(context.responseId ? [`response: ${context.responseId}`] : []), ].join(', '); return code ? l10n.t("The model provider reported a failed response with code '{0}' and no error message ({1}).", code, details) : l10n.t("The model provider reported a failed response without any error details ({0}).", details); } /** * Map a Responses-API `response.error` (string-coded per the OpenAI SDK) onto * our {@link APIErrorResponse} shape (numeric `code`). We can't preserve the * string code in `code`, so we stash it in `metadata.code` for BYOK diagnostics * (which `JSON.stringify` the whole struct). Providers do terminate streams with * an error object that omits `code`/`message` entirely, so those are described * rather than serialized as an empty struct that tells the user nothing. */ function mapResponsesApiError(err: OpenAI.Responses.ResponseError | null | undefined, context: IResponsesErrorContext): APIErrorResponse | undefined { if (!err) { return undefined; } const code = typeof err.code === 'string' && err.code ? err.code : undefined; const message = typeof err.message === 'string' && err.message ? err.message : undefined; return { code: 0, message: message ?? describeUninformativeResponsesError(code, context), // Omit absent keys so `JSON.stringify` cannot collapse metadata to `{}`. metadata: { ...(code ? { code } : {}), ...(context.responseId ? { responseId: context.responseId } : {}), }, }; } export class OpenAIResponsesProcessor { private textAccumulator: string = ''; private hasReceivedReasoningSummary = false; private sawCompactionMessage = false; private latestCompactionOutputIndex: number | undefined; private latestCompactionItem: OpenAIContextManagementResponse | undefined; /** Tracks the output_index of the last text delta to detect output item boundaries */ private lastTextDeltaOutputIndex: number | undefined; /** Maps output_index to { name, callId, arguments } for streaming tool call updates */ private readonly toolCallInfo = new Map(); /** * Raw `X-GitHub-Copilot-Request-Te` value for this model call. Settable after construction * because WebSocket turns receive it in a message envelope after the request has started. */ gitHubCopilotRequestTe: string | undefined; constructor( private readonly telemetryData: TelemetryData, private readonly telemetryService: ITelemetryService, private readonly requestId: string, private readonly ghRequestId: string, private readonly copilotServiceRequestId: string, private readonly serverExperiments: string, private readonly compactionThreshold: number | undefined, @ILogService private readonly logService: ILogService, ) { } private getCompactionItemsInChunk(chunk: OpenAI.Responses.ResponseStreamEvent): CompactionItemInChunk[] { const compactionItems: CompactionItemInChunk[] = []; if (hasOutputItem(chunk) && isCompactionItem(chunk.item)) { const outputIndex = getOutputItemIndex(chunk); compactionItems.push({ item: chunk.item, outputIndex }); } if (hasResponseOutput(chunk)) { for (let idx = 0; idx < chunk.response.output.length; idx++) { const item = chunk.response.output[idx]; if (isCompactionItem(item)) { compactionItems.push({ item, outputIndex: idx }); } } } return compactionItems; } private captureCompactionItem(item: OpenAIContextManagementResponse, outputIndex: number | undefined, onProgress: (delta: IResponseDelta) => undefined): void { if (outputIndex !== undefined && this.latestCompactionOutputIndex !== undefined && outputIndex < this.latestCompactionOutputIndex) { return; } const previousCompactionItem = this.latestCompactionItem; this.sawCompactionMessage = true; this.latestCompactionOutputIndex = outputIndex ?? this.latestCompactionOutputIndex; this.latestCompactionItem = item; if (previousCompactionItem?.id === item.id && previousCompactionItem.encrypted_content === item.encrypted_content) { return; } onProgress({ text: '', contextManagement: { type: openAIContextManagementCompactionType, id: item.id, encrypted_content: item.encrypted_content, } }); } public push(chunk: OpenAI.Responses.ResponseStreamEvent, _onProgress: FinishedCallback, canResumeResponses: (responseId: string) => boolean = () => true): ChatCompletion | undefined { const onProgress = (delta: IResponseDelta): undefined => { this.textAccumulator += delta.text; _onProgress(this.textAccumulator, 0, delta); }; const compactionItems = this.getCompactionItemsInChunk(chunk); if (chunk.type !== 'response.completed') { for (const { item, outputIndex } of compactionItems) { this.captureCompactionItem(item, outputIndex, onProgress); } } switch (chunk.type) { case 'error': // Surface the error as a progress delta, but also produce a terminal // completion so the request resolves to a meaningful server error // instead of collapsing into the generic "Response contained no // choices" fallback when the stream ends without a terminal event. onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] }); return this.buildTerminalCompletion( { output: [] } as unknown as CapiResponseTerminalEvent['response'], FinishedCompletionReason.ServerError, { error: mapResponsesApiError({ code: chunk.code, message: chunk.message } as OpenAI.Responses.ResponseError, toResponsesErrorContext('error')) } ); case 'response.output_text.delta': { const capiChunk: CapiResponsesTextDeltaEvent = chunk; // When text arrives from a new output item, emit a paragraph // separator so that e.g. commentary and final text don't fuse. if (this.lastTextDeltaOutputIndex !== undefined && capiChunk.output_index !== this.lastTextDeltaOutputIndex) { onProgress({ text: '\n\n' }); } this.lastTextDeltaOutputIndex = capiChunk.output_index; const haystack = new Lazy(() => new TextEncoder().encode(capiChunk.delta)); return onProgress({ text: capiChunk.delta, logprobs: capiChunk.logprobs && { content: capiChunk.logprobs.map(lp => ({ ...mapLogProp(haystack, lp), top_logprobs: lp.top_logprobs?.map(l => mapLogProp(haystack, l)) || [] })) }, }); } case 'response.output_item.added': if (chunk.item.type === 'function_call') { this.toolCallInfo.set(chunk.output_index, { name: chunk.item.name, callId: chunk.item.call_id, arguments: '' }); onProgress({ text: '', beginToolCalls: [{ name: chunk.item.name, id: chunk.item.call_id }] }); } else if (chunk.item.type.toString() === 'tool_search_call') { const tsItem = chunk.item as unknown as ResponsesToolSearchCall; if (tsItem.execution === 'client' && tsItem.call_id) { // Client-executed tool search: treat as a regular tool call so our ToolSearchTool handles it. this.toolCallInfo.set(chunk.output_index, { name: CUSTOM_TOOL_SEARCH_NAME, callId: tsItem.call_id, arguments: '' }); onProgress({ text: '', beginToolCalls: [{ name: CUSTOM_TOOL_SEARCH_NAME, id: tsItem.call_id }] }); } } return; case 'response.function_call_arguments.delta': { const info = this.toolCallInfo.get(chunk.output_index); if (info) { info.arguments += chunk.delta; onProgress({ text: '', copilotToolCallStreamUpdates: [{ id: info.callId, name: info.name, arguments: info.arguments, }], }); } return; } case 'response.output_item.done': if (chunk.item.type === 'function_call') { this.toolCallInfo.delete(chunk.output_index); onProgress({ text: '', copilotToolCalls: [{ id: chunk.item.call_id, name: chunk.item.name, arguments: chunk.item.arguments, }], phase: (chunk.item as ResponseOutputItemWithPhase).phase }); } else if (chunk.item.type.toString() === 'tool_search_call') { const tsCall = chunk.item as unknown as ResponsesToolSearchCall; if (tsCall.execution === 'client' && tsCall.call_id) { // Client-executed tool search completed: emit as a completed copilotToolCall this.toolCallInfo.delete(chunk.output_index); onProgress({ text: '', copilotToolCalls: [{ id: tsCall.call_id, name: CUSTOM_TOOL_SEARCH_NAME, arguments: JSON.stringify(tsCall.arguments ?? {}), }], }); } } else if (chunk.item.type === 'reasoning') { onProgress({ text: '', thinking: chunk.item.encrypted_content ? { id: chunk.item.id, // CAPI models don't stream the reasoning summary for some reason, byok do, so don't duplicate it text: this.hasReceivedReasoningSummary ? undefined : chunk.item.summary.map(s => s.text), encrypted: chunk.item.encrypted_content, } : undefined }); } else if (chunk.item.type === 'message') { onProgress({ text: '', phase: (chunk.item as ResponseOutputItemWithPhase).phase }); } return; case 'response.reasoning_summary_text.delta': this.hasReceivedReasoningSummary = true; return onProgress({ text: '', thinking: { id: chunk.item_id, text: chunk.delta, } }); case 'response.reasoning_summary_part.done': this.hasReceivedReasoningSummary = true; return onProgress({ text: '', thinking: { id: chunk.item_id, metadata: { vscode_reasoning_summary_part_done: true }, } }); case 'response.completed': { const capiChunk = chunk as CapiResponseCompletedEvent; const normalizedOutput = keepLatestCompactionOutput(capiChunk.response.output, this.latestCompactionOutputIndex); const latestCompactionOutput = getLatestCompactionOutput(normalizedOutput, this.latestCompactionOutputIndex); const latestCompactionItem = latestCompactionOutput?.item; const previousCompactionItem = this.latestCompactionItem; if (latestCompactionItem) { this.sawCompactionMessage = true; this.latestCompactionOutputIndex = latestCompactionOutput.outputIndex; } const shouldEmitResolvedCompaction = latestCompactionItem && ( !previousCompactionItem || previousCompactionItem.id !== latestCompactionItem.id || previousCompactionItem.encrypted_content !== latestCompactionItem.encrypted_content ); if (latestCompactionItem) { this.latestCompactionItem = latestCompactionItem; } if (this.compactionThreshold !== undefined && this.sawCompactionMessage) { const promptTokens = chunk.response.usage?.input_tokens ?? 0; const totalTokens = chunk.response.usage?.total_tokens ?? 0; sendResponsesApiCompactionTelemetry(this.telemetryService, { outcome: 'compaction_returned', headerRequestId: this.requestId, gitHubRequestId: this.ghRequestId, model: chunk.response.model, ...gitHubCopilotRequestTeProperty(this.gitHubCopilotRequestTe), }, { compactThreshold: this.compactionThreshold, promptTokens, totalTokens, }); this.logService.debug(`[responsesAPI_compaction] Compaction enabled. headerRequestId=${this.requestId}`); } else if (this.compactionThreshold !== undefined && (chunk.response.usage?.input_tokens ?? 0) >= this.compactionThreshold) { const promptTokens = chunk.response.usage?.input_tokens ?? 0; const totalTokens = chunk.response.usage?.total_tokens ?? 0; sendResponsesApiCompactionTelemetry(this.telemetryService, { outcome: 'threshold_met_no_compaction', headerRequestId: this.requestId, gitHubRequestId: this.ghRequestId, model: chunk.response.model, ...gitHubCopilotRequestTeProperty(this.gitHubCopilotRequestTe), }, { compactThreshold: this.compactionThreshold, promptTokens, totalTokens, }); this.logService.debug(`[responsesAPI_compaction] Compaction enabled but context not compacted after threshold was met. headerRequestId=${this.requestId}, gitHubRequestId=${this.ghRequestId}, promptTokens=${promptTokens}, totalTokens=${totalTokens}`); } onProgress({ text: '', statefulMarker: canResumeResponses(chunk.response.id) ? chunk.response.id : undefined, contextManagement: shouldEmitResolvedCompaction ? latestCompactionItem : undefined, }); return { blockFinished: true, choiceIndex: 0, model: chunk.response.model, tokens: [], telemetryData: this.telemetryData, requestId: { headerRequestId: this.requestId, gitHubRequestId: this.ghRequestId, copilotServiceRequestId: this.copilotServiceRequestId, completionId: chunk.response.id, created: chunk.response.created_at, deploymentId: '', serverExperiments: this.serverExperiments, ...gitHubCopilotRequestTeProperty(this.gitHubCopilotRequestTe) }, usage: { prompt_tokens: chunk.response.usage?.input_tokens ?? 0, completion_tokens: chunk.response.usage?.output_tokens ?? 0, total_tokens: chunk.response.usage?.total_tokens ?? 0, prompt_tokens_details: { cached_tokens: chunk.response.usage?.input_tokens_details?.cached_tokens ?? 0, }, completion_tokens_details: { reasoning_tokens: chunk.response.usage?.output_tokens_details?.reasoning_tokens ?? 0, accepted_prediction_tokens: 0, rejected_prediction_tokens: 0, }, copilot_usage: capiChunk.copilot_usage?.total_nano_aiu !== undefined ? capiChunk.copilot_usage : undefined, }, finishReason: FinishedCompletionReason.Stop, telemetryMessages: responseApiInputToTelemetryMessages({ model: chunk.response.model, input: normalizedOutput }), message: { role: Raw.ChatRole.Assistant, content: normalizedOutput.map((item): Raw.ChatCompletionContentPart | undefined => { if (item.type === 'message') { return { type: Raw.ChatCompletionContentPartKind.Text, text: item.content.map(c => c.type === 'output_text' ? c.text : c.refusal).join('') }; } else if (item.type === 'image_generation_call' && item.result) { return { type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: item.result } }; } }).filter(isDefined), } }; } case 'response.incomplete': { const incomplete = chunk.response as CapiResponseTerminalEvent['response']; const reason = incomplete.incomplete_details?.reason; let finishReason: FinishedCompletionReason; let filterReason: FilterReason | undefined; if (reason === 'max_output_tokens') { finishReason = FinishedCompletionReason.Length; } else if (reason === 'content_filter') { finishReason = FinishedCompletionReason.ContentFilter; filterReason = extractFilterReasonFromContentFilters(incomplete.content_filters); } else { // Unknown incomplete reason — treat as a server-side stream termination so the // caller surfaces a "request failed" message instead of the generic flake. finishReason = FinishedCompletionReason.ServerError; } return this.buildTerminalCompletion(incomplete, finishReason, { filterReason, error: mapResponsesApiError(incomplete.error, toResponsesErrorContext('response.incomplete', incomplete)), }); } case 'response.failed': { const failed = chunk.response as CapiResponseTerminalEvent['response']; return this.buildTerminalCompletion(failed, FinishedCompletionReason.ServerError, { error: mapResponsesApiError(failed.error, toResponsesErrorContext('response.failed', failed)), }); } } } /** * Build a {@link ChatCompletion} for a terminal Responses API event other than * `response.completed` (i.e. `response.incomplete` or `response.failed`). The * resulting completion is fed into the same downstream switch as a normal * completion so callers can map it to the appropriate user-facing error. */ private buildTerminalCompletion( response: CapiResponseTerminalEvent['response'], finishReason: FinishedCompletionReason, opts: { filterReason?: FilterReason; error?: APIErrorResponse } = {} ): ChatCompletion { const output = response.output ?? []; return { blockFinished: true, choiceIndex: 0, model: response.model, tokens: [], telemetryData: this.telemetryData, requestId: { headerRequestId: this.requestId, gitHubRequestId: this.ghRequestId, copilotServiceRequestId: this.copilotServiceRequestId, completionId: response.id, created: response.created_at, deploymentId: '', serverExperiments: this.serverExperiments, ...gitHubCopilotRequestTeProperty(this.gitHubCopilotRequestTe), }, usage: response.usage ? { prompt_tokens: response.usage.input_tokens ?? 0, completion_tokens: response.usage.output_tokens ?? 0, total_tokens: response.usage.total_tokens ?? 0, prompt_tokens_details: { cached_tokens: response.usage.input_tokens_details?.cached_tokens ?? 0, }, completion_tokens_details: { reasoning_tokens: response.usage.output_tokens_details?.reasoning_tokens ?? 0, accepted_prediction_tokens: 0, rejected_prediction_tokens: 0, }, } : undefined, finishReason, filterReason: opts.filterReason, error: opts.error, telemetryMessages: responseApiInputToTelemetryMessages({ model: response.model, input: output }), message: { role: Raw.ChatRole.Assistant, content: output.map((item): Raw.ChatCompletionContentPart | undefined => { if (item.type === 'message') { return { type: Raw.ChatCompletionContentPartKind.Text, text: item.content.map(c => c.type === 'output_text' ? c.text : c.refusal).join('') }; } else if (item.type === 'image_generation_call' && item.result) { return { type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: item.result } }; } }).filter(isDefined), }, }; } } function mapLogProp(text: Lazy, lp: OpenAI.Responses.ResponseTextDeltaEvent.Logprob.TopLogprob): TokenLogProb { let bytes: number[] = []; if (lp.token) { const needle = new TextEncoder().encode(lp.token); const haystack = text.value; const idx = binaryIndexOf(haystack, needle); if (idx !== -1) { bytes = [idx, idx + needle.length]; } } return { token: lp.token!, bytes, logprob: lp.logprob!, }; }