Path: blob/main/extensions/copilot/src/extension/agents/node/adapters/openaiAdapterForSTests.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Raw } from '@vscode/prompt-tsx';6import { ChatCompletionContentPartKind } from '@vscode/prompt-tsx/dist/base/output/rawTypes';7import * as http from 'http';8import { ChatCompletionChunk, ChatCompletionCreateParamsBase, ChatCompletionMessageParam } from 'openai/resources/chat/completions.js';9import type { OpenAiFunctionTool } from '../../../../platform/networking/common/fetch';10import { IMakeChatRequestOptions } from '../../../../platform/networking/common/networking';11import { APIUsage } from '../../../../platform/networking/common/openai';12import { coalesce } from '../../../../util/vs/base/common/arrays';13import { IAgentStreamBlock, IParsedRequest, IProtocolAdapter, IProtocolAdapterFactory, IStreamEventData, IStreamingContext } from './types';1415export class OpenAIAdapterFactoryForSTests implements IProtocolAdapterFactory {16private readonly requestHooks: ((body: string) => string)[] = [];17private readonly responseHooks: ((body: string) => string)[] = [];18createAdapter(): IProtocolAdapter {19return new OpenAIAdapterForSTests(this.requestHooks, this.responseHooks);20}21public addHooks(requestHook?: (body: string) => string, responseHook?: (body: string) => string): void {22if (requestHook) {23this.requestHooks.push(requestHook);24}25if (responseHook) {26this.responseHooks.push(responseHook);27}28}29}3031class OpenAIAdapterForSTests implements IProtocolAdapter {32readonly name = 'openai';3334// Per-request state35private currentBlockIndex = 0;36private hasTextBlock = false;37private hadToolCalls = false;38constructor(private readonly requestHooks: ((body: string) => string)[], private readonly responseHooks: ((body: string) => string)[] = []) {39// No-op for test adapter40}4142parseRequest(body: string): IParsedRequest {43body = this.requestHooks.reduce((b, hook) => hook(b), body);44const requestBody: ChatCompletionCreateParamsBase = JSON.parse(body);4546// Extract model information47const model = requestBody.model;4849// Convert messages format if needed50const runHooks = (msg: string) => {51return this.requestHooks.reduce((b, hook) => hook(b), msg);52};53const messages = responseApiInputToRawMessages(requestBody.messages);54messages.forEach(msg => {55msg.content.forEach(part => {56switch (part.type) {57case ChatCompletionContentPartKind.Image: {58part.imageUrl.url = runHooks(part.imageUrl.url);59break;60}61case ChatCompletionContentPartKind.Opaque: {62if (typeof part.value === 'string') {63part.value = runHooks(part.value);64}65break;66}67case ChatCompletionContentPartKind.Text: {68part.text = runHooks(part.text);69break;70}71}72});73});7475const options: IMakeChatRequestOptions['requestOptions'] = {76temperature: (requestBody.temperature ?? undefined),77max_tokens: (requestBody.max_tokens ?? requestBody.max_completion_tokens) ?? undefined,78};7980if (requestBody.tools && Array.isArray(requestBody.tools) && requestBody.tools.length > 0) {81// Map OpenAI tools to VS Code chat tools82const tools = coalesce(requestBody.tools.map((tool) => {83if (tool.type === 'function' && tool.function) {84const chatTool: OpenAiFunctionTool = {85type: 'function',86function: {87name: tool.function.name,88description: tool.function.description || '',89parameters: tool.function.parameters || {},90}91};92return chatTool;93}94return undefined;95}));96if (tools.length) {97options.tools = tools;98}99}100101return {102model,103messages,104options105};106}107108109private readonly textMessages = new Map<string, string>();110111private collectTextContent(context: IStreamingContext, content: string): void {112const existing = this.textMessages.get(context.requestId) || '';113this.textMessages.set(context.requestId, existing + content);114}115private getCollectedTextContent(context: IStreamingContext): IStreamEventData | undefined {116let content = this.textMessages.get(context.requestId);117if (typeof content !== 'string') {118return undefined;119}120this.textMessages.delete(context.requestId);121content = this.responseHooks.reduce((b, hook) => hook(b), content);122123// Send text delta events124const event = {125id: context.requestId,126object: 'chat.completion.chunk',127created: Math.floor(Date.now() / 1000),128model: context.endpoint.modelId,129choices: [{130index: this.currentBlockIndex,131delta: {132content,133role: 'assistant'134},135finish_reason: null136}]137} satisfies ChatCompletionChunk;138139return {140event: 'message',141data: this.formatEventData(event)142};143}144formatStreamResponse(145streamData: IAgentStreamBlock,146context: IStreamingContext147): IStreamEventData[] {148const events: IStreamEventData[] = [];149150if (streamData.type === 'text') {151if (!this.hasTextBlock) {152this.hasTextBlock = true;153}154155// Collect all of the strings, as there could be references to file paths.156// At the end of the stream, we will send a single event with the full text & have file paths replaced.157this.collectTextContent(context, streamData.content);158} else if (streamData.type === 'tool_call') {159// End current text block if it exists160if (this.hasTextBlock) {161const event = this.getCollectedTextContent(context);162if (event) {163events.push(event);164}165this.currentBlockIndex++;166this.hasTextBlock = false;167}168169this.hadToolCalls = true;170171// Arguments can contain file paths.172const toolArguments = this.responseHooks.reduce((b, hook) => hook(b), JSON.stringify(streamData.input || {}));173174// Send tool call events175const toolCallDelta: ChatCompletionChunk = {176id: context.requestId,177object: 'chat.completion.chunk',178created: Math.floor(Date.now() / 1000),179model: context.endpoint.modelId,180choices: [{181index: this.currentBlockIndex,182delta: {183tool_calls: [{184index: this.currentBlockIndex,185id: streamData.callId,186type: 'function',187function: {188name: streamData.name,189arguments: toolArguments190}191}]192},193finish_reason: null194}]195};196events.push({197event: 'message',198data: this.formatEventData(toolCallDelta)199});200201this.currentBlockIndex++;202}203204return events;205}206207generateFinalEvents(context: IStreamingContext, usage?: APIUsage): IStreamEventData[] {208const events: IStreamEventData[] = [];209210const event = this.getCollectedTextContent(context);211if (event) {212events.push(event);213}214215// Send final completion event with usage information216const finalCompletion = {217id: context.requestId,218object: 'chat.completion.chunk',219created: Math.floor(Date.now() / 1000),220model: context.endpoint.modelId,221choices: [{222index: 0,223delta: { content: null },224finish_reason: this.hadToolCalls ? 'tool_calls' : 'stop'225}],226usage: usage ? {227prompt_tokens: usage.prompt_tokens,228completion_tokens: usage.completion_tokens,229total_tokens: usage.total_tokens230} : {231prompt_tokens: 0,232completion_tokens: 0,233total_tokens: 0234}235} satisfies ChatCompletionChunk;236237events.push({238event: 'message',239data: this.formatEventData(finalCompletion)240});241242return events;243}244245generateInitialEvents(context: IStreamingContext): IStreamEventData[] {246// OpenAI doesn't typically send initial events, but we can send an empty one if needed247return [];248}249250getContentType(): string {251return 'text/event-stream';252}253254extractAuthKey(headers: http.IncomingHttpHeaders): string | undefined {255const authHeader = headers.authorization;256const bearerSpace = 'Bearer ';257return authHeader?.startsWith(bearerSpace) ? authHeader.substring(bearerSpace.length) : undefined;258}259260private formatEventData(data: unknown): string {261return JSON.stringify(data).replace(/\n/g, '\\n');262}263}264function responseApiInputToRawMessages(messages: ChatCompletionMessageParam[]): Raw.ChatMessage[] {265const raw: Raw.ChatMessage[] = [];266267// Helper to push or merge consecutive messages of same role268const pushOrMerge = (msg: Raw.ChatMessage) => {269const last = raw[raw.length - 1];270if (last && last.role === msg.role && last.role !== Raw.ChatRole.Tool) {271// Merge content arrays272last.content.push(...msg.content);273// Merge tool calls if assistant274if (last.role === Raw.ChatRole.Assistant && msg.role === Raw.ChatRole.Assistant && msg.toolCalls) {275const l = last as Raw.AssistantChatMessage;276l.toolCalls = [...(l.toolCalls || []), ...((msg as Raw.AssistantChatMessage).toolCalls || [])];277}278} else {279raw.push(msg);280}281};282283messages.forEach(m => {284// Collect content parts285const contentParts: Raw.ChatCompletionContentPart[] = [];286287// OpenAI message content can be string or ChatCompletionContentPart[]288(Array.isArray(m.content) ? m.content : []).forEach(part => {289switch (part.type) {290case 'text': {291contentParts.push({ type: Raw.ChatCompletionContentPartKind.Text, text: part.text });292break;293}294case 'image_url': {295contentParts.push({ imageUrl: { url: part.image_url.url, detail: part.image_url.detail as unknown as ('low' | 'high' | undefined) }, type: ChatCompletionContentPartKind.Image });296break;297}298case 'file': {299contentParts.push({ type: ChatCompletionContentPartKind.Opaque, value: `[File Input - Filename: ${part.file.filename}]` });300break;301}302case 'refusal': {303// Refusal parts contain a 'refusal' field; access defensively304contentParts.push({ type: Raw.ChatCompletionContentPartKind.Text, text: `[Refusal: ${part.refusal || ''}]` });305break;306}307case 'input_audio':308default: {309// Unknown part310}311}312});313if (typeof m.content === 'string') {314contentParts.push({ type: Raw.ChatCompletionContentPartKind.Text, text: m.content });315}316317switch (m.role) {318case 'user': {319pushOrMerge({ role: Raw.ChatRole.User, content: contentParts });320return;321}322case 'tool': {323// contentParts.splice(0, contentParts.length);324raw.push({ role: Raw.ChatRole.Tool, content: contentParts, toolCallId: m.tool_call_id || '' });325return;326327}328case 'assistant': {329const toolCalls: Raw.ChatMessageToolCall[] = (m.tool_calls || []).map(tc => {330try {331if (tc.type === 'function') {332return {333id: tc.id || tc.function.name || 'tool_call',334type: 'function',335function: {336name: tc.function.name || 'unknown_function',337arguments: typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments ?? {})338}339} satisfies Raw.ChatMessageToolCall;340}341} catch { }342// Fallback minimal tool call343return { id: 'tool_call', type: 'function', function: { name: 'unknown_function', arguments: '{}' } } satisfies Raw.ChatMessageToolCall;344});345const message: Raw.AssistantChatMessage = { role: Raw.ChatRole.Assistant, content: contentParts };346if (toolCalls.length) {347message.toolCalls = toolCalls;348}349pushOrMerge(message);350return;351}352case 'system':353case 'developer': {354// System (and any unexpected) messages355pushOrMerge({ role: Raw.ChatRole.System, content: contentParts, name: m.name });356return;357}358default: {359return;360}361}362});363364return raw;365}366367368369