mirror of
https://github.com/larsbaunwall/vscode-copilot-bridge.git
synced 2025-10-05 22:22:59 +00:00
Refactor code to be more clean and modularized. Bump package version
This commit is contained in:
parent
ef1526c76a
commit
70a077ca51
11 changed files with 701 additions and 251 deletions
|
|
@ -4,7 +4,7 @@
|
|||
"name": "copilot-bridge",
|
||||
"displayName": "Copilot Bridge",
|
||||
"description": "Local OpenAI-compatible chat endpoint (inference) bridging to GitHub Copilot via the VS Code Language Model API.",
|
||||
"version": "0.2.2",
|
||||
"version": "1.0.0",
|
||||
"publisher": "thinkability",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -1,54 +1,20 @@
|
|||
import * as vscode from 'vscode';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { state } from '../../state';
|
||||
import { getBridgeConfig } from '../../config';
|
||||
import { isChatCompletionRequest, normalizeMessagesLM, convertOpenAIToolsToLM, convertFunctionsToTools } from '../../messages';
|
||||
import { getModel, hasLMApi } from '../../models';
|
||||
import { readJson, writeErrorResponse, writeJson } from '../utils';
|
||||
import { isChatCompletionRequest, type ChatCompletionRequest } from '../../messages';
|
||||
import { readJson, writeErrorResponse } from '../utils';
|
||||
import { verbose } from '../../log';
|
||||
import { ModelService } from '../../services/model-service';
|
||||
import { StreamingResponseHandler } from '../../services/streaming-handler';
|
||||
import { processLanguageModelResponse, sendCompletionResponse } from '../../services/response-formatter';
|
||||
import type { ChatCompletionContext } from '../../types/openai-types';
|
||||
|
||||
// OpenAI response interfaces for better typing
|
||||
interface OpenAIToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenAIMessage {
|
||||
role: 'assistant';
|
||||
content: string | null;
|
||||
tool_calls?: OpenAIToolCall[];
|
||||
function_call?: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenAIChoice {
|
||||
index: number;
|
||||
message?: OpenAIMessage;
|
||||
delta?: Partial<OpenAIMessage>;
|
||||
finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call' | null;
|
||||
}
|
||||
|
||||
interface OpenAIResponse {
|
||||
id: string;
|
||||
object: 'chat.completion' | 'chat.completion.chunk';
|
||||
created: number;
|
||||
model: string;
|
||||
choices: OpenAIChoice[];
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const handleChatCompletion = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
const config = getBridgeConfig();
|
||||
/**
|
||||
* Handles OpenAI-compatible chat completion requests with support for streaming and tool calling
|
||||
* @param req - HTTP request object
|
||||
* @param res - HTTP response object
|
||||
*/
|
||||
export async function handleChatCompletion(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
state.activeRequests++;
|
||||
verbose(`Request started (active=${state.activeRequests})`);
|
||||
|
||||
|
|
@ -58,202 +24,75 @@ export const handleChatCompletion = async (req: IncomingMessage, res: ServerResp
|
|||
return writeErrorResponse(res, 400, 'invalid request', 'invalid_request_error', 'invalid_payload');
|
||||
}
|
||||
|
||||
const requestedModel = body.model;
|
||||
const stream = body.stream !== false; // default true
|
||||
const modelService = new ModelService();
|
||||
|
||||
// Handle tools and deprecated functions
|
||||
let tools = body.tools || [];
|
||||
if (body.functions) {
|
||||
// Convert deprecated functions to tools format
|
||||
tools = [...tools, ...convertFunctionsToTools(body.functions)];
|
||||
}
|
||||
|
||||
const model = await getModel(false, requestedModel);
|
||||
|
||||
if (!model) {
|
||||
const hasLM = hasLMApi();
|
||||
if (requestedModel && hasLM) {
|
||||
state.lastReason = 'not_found';
|
||||
return writeErrorResponse(res, 404, 'model not found', 'invalid_request_error', 'model_not_found', 'not_found');
|
||||
}
|
||||
const reason = !hasLM ? 'missing_language_model_api' : (state.lastReason || 'copilot_model_unavailable');
|
||||
return writeErrorResponse(res, 503, 'Copilot unavailable', 'server_error', 'copilot_unavailable', reason);
|
||||
// Validate model availability
|
||||
const modelValidation = await modelService.validateModel(body.model);
|
||||
if (!modelValidation.isValid) {
|
||||
const errorMessage = body.model ? 'model not found' : 'Copilot unavailable';
|
||||
return writeErrorResponse(
|
||||
res,
|
||||
modelValidation.statusCode!,
|
||||
errorMessage,
|
||||
modelValidation.errorType!,
|
||||
modelValidation.errorCode!,
|
||||
modelValidation.reason || 'unknown_error'
|
||||
);
|
||||
}
|
||||
|
||||
const lmMessages = normalizeMessagesLM(body.messages, config.historyWindow) as vscode.LanguageModelChatMessage[];
|
||||
const lmTools = convertOpenAIToolsToLM(tools);
|
||||
// Create processing context
|
||||
const context = await modelService.createProcessingContext(body);
|
||||
const chatContext = modelService.createChatCompletionContext(body, context.lmTools.length > 0);
|
||||
|
||||
// Prepare request options for Language Model API
|
||||
const requestOptions: any = {};
|
||||
if (lmTools.length > 0) {
|
||||
requestOptions.tools = lmTools;
|
||||
}
|
||||
|
||||
verbose(`LM request via API model=${model.family || model.id || model.name || 'unknown'} tools=${lmTools.length}`);
|
||||
verbose(`LM request via API model=${context.model.family || context.model.id || context.model.name || 'unknown'} tools=${context.lmTools.length}`);
|
||||
|
||||
const cts = new vscode.CancellationTokenSource();
|
||||
const response = await model.sendRequest(lmMessages, requestOptions, cts.token);
|
||||
await sendResponse(res, response, stream, body, tools);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
writeErrorResponse(res, 500, msg || 'internal_error', 'server_error', 'internal_error');
|
||||
// Execute the Language Model request
|
||||
const cancellationToken = new vscode.CancellationTokenSource();
|
||||
const response = await context.model.sendRequest(
|
||||
context.lmMessages,
|
||||
context.requestOptions,
|
||||
cancellationToken.token
|
||||
);
|
||||
|
||||
// Handle response based on streaming preference
|
||||
if (chatContext.isStreaming) {
|
||||
await handleStreamingResponse(res, response, chatContext, body);
|
||||
} else {
|
||||
await handleNonStreamingResponse(res, response, chatContext, body);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
writeErrorResponse(res, 500, errorMessage || 'internal_error', 'server_error', 'internal_error');
|
||||
} finally {
|
||||
state.activeRequests--;
|
||||
verbose(`Request complete (active=${state.activeRequests})`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const sendResponse = async (
|
||||
res: ServerResponse,
|
||||
response: vscode.LanguageModelChatResponse,
|
||||
stream: boolean,
|
||||
requestBody?: any,
|
||||
tools?: any[]
|
||||
): Promise<void> => {
|
||||
const modelName = requestBody?.model || 'copilot';
|
||||
const responseId = `chatcmpl-${Math.random().toString(36).slice(2)}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
/**
|
||||
* Handles streaming response using Server-Sent Events
|
||||
*/
|
||||
async function handleStreamingResponse(
|
||||
res: ServerResponse,
|
||||
response: vscode.LanguageModelChatResponse,
|
||||
chatContext: ChatCompletionContext,
|
||||
requestBody: ChatCompletionRequest
|
||||
): Promise<void> {
|
||||
const streamHandler = new StreamingResponseHandler(res, chatContext, requestBody);
|
||||
streamHandler.initializeStream();
|
||||
await streamHandler.processAndStreamResponse(response);
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
|
||||
verbose(`SSE start id=${responseId}`);
|
||||
|
||||
let toolCalls: OpenAIToolCall[] = [];
|
||||
|
||||
for await (const part of response.stream) {
|
||||
// Check if this part is a LanguageModelToolCallPart
|
||||
if (part && typeof part === 'object' && 'callId' in part && 'name' in part && 'input' in part) {
|
||||
const toolCallPart = part as vscode.LanguageModelToolCallPart;
|
||||
const toolCall: OpenAIToolCall = {
|
||||
id: toolCallPart.callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCallPart.name,
|
||||
arguments: JSON.stringify(toolCallPart.input)
|
||||
}
|
||||
};
|
||||
toolCalls.push(toolCall);
|
||||
|
||||
// Send tool call in streaming format
|
||||
const chunkResponse: OpenAIResponse = {
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model: modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [toolCall]
|
||||
},
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(chunkResponse)}\n\n`);
|
||||
} else if (typeof part === 'string' || (part && typeof part === 'object' && 'value' in part)) {
|
||||
// Handle text content
|
||||
const content = typeof part === 'string' ? part : (part as any).value || '';
|
||||
if (content) {
|
||||
const chunkResponse: OpenAIResponse = {
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model: modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(chunkResponse)}\n\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send final chunk
|
||||
const finishReason: OpenAIChoice['finish_reason'] = toolCalls.length > 0 ? 'tool_calls' : 'stop';
|
||||
const finalChunkResponse: OpenAIResponse = {
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model: modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: finishReason
|
||||
}]
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(finalChunkResponse)}\n\n`);
|
||||
|
||||
verbose(`SSE end id=${responseId}`);
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-streaming response
|
||||
let content = '';
|
||||
let toolCalls: OpenAIToolCall[] = [];
|
||||
|
||||
for await (const part of response.stream) {
|
||||
if (part && typeof part === 'object' && 'callId' in part && 'name' in part && 'input' in part) {
|
||||
// Handle VS Code LanguageModelToolCallPart
|
||||
const toolCallPart = part as vscode.LanguageModelToolCallPart;
|
||||
const toolCall: OpenAIToolCall = {
|
||||
id: toolCallPart.callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCallPart.name,
|
||||
arguments: JSON.stringify(toolCallPart.input)
|
||||
}
|
||||
};
|
||||
toolCalls.push(toolCall);
|
||||
} else if (typeof part === 'string' || (part && typeof part === 'object' && 'value' in part)) {
|
||||
// Handle text content
|
||||
content += typeof part === 'string' ? part : (part as any).value || '';
|
||||
}
|
||||
}
|
||||
|
||||
verbose(`Non-stream complete len=${content.length} tool_calls=${toolCalls.length}`);
|
||||
|
||||
const message: OpenAIMessage = {
|
||||
role: 'assistant',
|
||||
content: toolCalls.length > 0 ? null : content,
|
||||
};
|
||||
|
||||
// Add tool_calls if present
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls;
|
||||
|
||||
// For backward compatibility, also add function_call if there's exactly one tool call
|
||||
if (toolCalls.length === 1 && requestBody?.function_call !== undefined) {
|
||||
message.function_call = {
|
||||
name: toolCalls[0].function.name,
|
||||
arguments: toolCalls[0].function.arguments
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const responseObj: OpenAIResponse = {
|
||||
id: responseId,
|
||||
object: 'chat.completion',
|
||||
created,
|
||||
model: modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message,
|
||||
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 0, // VS Code API doesn't provide token counts
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0
|
||||
}
|
||||
};
|
||||
|
||||
writeJson(res, 200, responseObj);
|
||||
};
|
||||
/**
|
||||
* Handles non-streaming response with complete data
|
||||
*/
|
||||
async function handleNonStreamingResponse(
|
||||
res: ServerResponse,
|
||||
response: vscode.LanguageModelChatResponse,
|
||||
chatContext: ChatCompletionContext,
|
||||
requestBody: ChatCompletionRequest
|
||||
): Promise<void> {
|
||||
const processedData = await processLanguageModelResponse(response);
|
||||
sendCompletionResponse(res, chatContext, processedData, requestBody);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,42 @@ import { hasLMApi, getModel } from '../../models';
|
|||
import { state } from '../../state';
|
||||
import { verbose } from '../../log';
|
||||
|
||||
interface HealthResponse {
|
||||
readonly ok: boolean;
|
||||
readonly status: string;
|
||||
readonly copilot: string;
|
||||
readonly reason?: string;
|
||||
readonly version: string;
|
||||
readonly features: {
|
||||
readonly chat_completions: boolean;
|
||||
readonly streaming: boolean;
|
||||
readonly tool_calling: boolean;
|
||||
readonly function_calling: boolean;
|
||||
readonly models_list: boolean;
|
||||
};
|
||||
readonly active_requests: number;
|
||||
readonly model_attempted?: boolean;
|
||||
}
|
||||
|
||||
export const handleHealthCheck = async (res: ServerResponse, v: boolean): Promise<void> => {
|
||||
const hasLM = hasLMApi();
|
||||
|
||||
// Attempt model resolution if cache is empty and verbose logging is enabled
|
||||
if (!state.modelCache && v) {
|
||||
verbose(`Healthz: model=${state.modelCache ? 'present' : 'missing'} lmApi=${hasLM ? 'ok' : 'missing'}`);
|
||||
await getModel();
|
||||
try {
|
||||
await getModel();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
verbose(`Health check model resolution failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const unavailableReason = state.modelCache
|
||||
? undefined
|
||||
: (!hasLM ? 'missing_language_model_api' : (state.lastReason || 'copilot_model_unavailable'));
|
||||
|
||||
writeJson(res, 200, {
|
||||
const response: HealthResponse = {
|
||||
ok: true,
|
||||
status: 'operational',
|
||||
copilot: state.modelCache ? 'ok' : 'unavailable',
|
||||
|
|
@ -30,5 +55,7 @@ export const handleHealthCheck = async (res: ServerResponse, v: boolean): Promis
|
|||
},
|
||||
active_requests: state.activeRequests,
|
||||
model_attempted: state.modelAttempted
|
||||
});
|
||||
};
|
||||
|
||||
writeJson(res, 200, response);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,31 @@
|
|||
import { writeJson } from '../utils';
|
||||
import { writeJson, writeErrorResponse } from '../utils';
|
||||
import { listCopilotModels } from '../../models';
|
||||
import { verbose } from '../../log';
|
||||
import type { ServerResponse } from 'http';
|
||||
|
||||
interface ModelObject {
|
||||
readonly id: string;
|
||||
readonly object: 'model';
|
||||
readonly created: number;
|
||||
readonly owned_by: string;
|
||||
readonly permission: readonly unknown[];
|
||||
readonly root: string;
|
||||
readonly parent: null;
|
||||
}
|
||||
|
||||
interface ModelsListResponse {
|
||||
readonly object: 'list';
|
||||
readonly data: readonly ModelObject[];
|
||||
}
|
||||
|
||||
export const handleModelsRequest = async (res: ServerResponse): Promise<void> => {
|
||||
try {
|
||||
const modelIds = await listCopilotModels();
|
||||
const models = modelIds.map((id: string) => ({
|
||||
verbose(`Models listed: ${modelIds.length} available`);
|
||||
|
||||
const models: ModelObject[] = modelIds.map((id: string) => ({
|
||||
id,
|
||||
object: 'model',
|
||||
object: 'model' as const,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'copilot',
|
||||
permission: [],
|
||||
|
|
@ -15,18 +33,15 @@ export const handleModelsRequest = async (res: ServerResponse): Promise<void> =>
|
|||
parent: null,
|
||||
}));
|
||||
|
||||
writeJson(res, 200, {
|
||||
const response: ModelsListResponse = {
|
||||
object: 'list',
|
||||
data: models,
|
||||
});
|
||||
};
|
||||
|
||||
writeJson(res, 200, response);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
writeJson(res, 500, {
|
||||
error: {
|
||||
message: msg || 'Failed to list models',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
});
|
||||
verbose(`Models request failed: ${msg}`);
|
||||
writeErrorResponse(res, 500, msg || 'Failed to list models', 'server_error', 'internal_error');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ export const handleModelSelectionError = (error: unknown, family?: string): void
|
|||
|
||||
export const listCopilotModels = async (): Promise<string[]> => {
|
||||
try {
|
||||
const models = await selectChatModels();
|
||||
// Filter for Copilot models only, consistent with getModel behavior
|
||||
const models = await vscode.lm.selectChatModels({ vendor: 'copilot' });
|
||||
const ids = models.map((m: vscode.LanguageModelChat) => {
|
||||
const normalized = m.family || m.id || m.name || 'copilot';
|
||||
return `${normalized}`;
|
||||
|
|
|
|||
99
src/services/model-service.ts
Normal file
99
src/services/model-service.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type * as vscode from 'vscode';
|
||||
import type { ChatCompletionRequest } from '../messages';
|
||||
import type {
|
||||
ModelValidationResult,
|
||||
RequestProcessingContext,
|
||||
ChatCompletionContext
|
||||
} from '../types/openai-types';
|
||||
import {
|
||||
extractAndMergeTools,
|
||||
createLanguageModelRequestOptions
|
||||
} from './request-processor';
|
||||
import { getModel, hasLMApi } from '../models';
|
||||
import { normalizeMessagesLM, convertOpenAIToolsToLM } from '../messages';
|
||||
import { getBridgeConfig } from '../config';
|
||||
|
||||
/**
|
||||
* Service for validating models and creating request processing context
|
||||
*/
|
||||
export class ModelService {
|
||||
|
||||
/**
|
||||
* Validates the requested model and returns appropriate error details if invalid
|
||||
* @param requestedModel - The model identifier from the request
|
||||
* @returns Validation result with error details if model is unavailable
|
||||
*/
|
||||
public async validateModel(requestedModel?: string): Promise<ModelValidationResult> {
|
||||
const model = await getModel(false, requestedModel);
|
||||
|
||||
if (!model) {
|
||||
const hasLM = hasLMApi();
|
||||
|
||||
if (requestedModel && hasLM) {
|
||||
return {
|
||||
isValid: false,
|
||||
statusCode: 404,
|
||||
errorType: 'invalid_request_error',
|
||||
errorCode: 'model_not_found',
|
||||
reason: 'not_found'
|
||||
};
|
||||
}
|
||||
|
||||
const reason = !hasLM ? 'missing_language_model_api' : 'copilot_model_unavailable';
|
||||
return {
|
||||
isValid: false,
|
||||
statusCode: 503,
|
||||
errorType: 'server_error',
|
||||
errorCode: 'copilot_unavailable',
|
||||
reason
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete request processing context from validated inputs
|
||||
* @param body - The validated chat completion request
|
||||
* @returns Processing context with all required elements for the Language Model API
|
||||
*/
|
||||
public async createProcessingContext(body: ChatCompletionRequest): Promise<RequestProcessingContext> {
|
||||
const model = await getModel(false, body.model);
|
||||
if (!model) {
|
||||
throw new Error('Model validation should be performed before creating processing context');
|
||||
}
|
||||
|
||||
const config = getBridgeConfig();
|
||||
const mergedTools = extractAndMergeTools(body);
|
||||
const lmMessages = normalizeMessagesLM(body.messages, config.historyWindow);
|
||||
const lmTools = convertOpenAIToolsToLM(mergedTools);
|
||||
const requestOptions = createLanguageModelRequestOptions(lmTools);
|
||||
|
||||
return {
|
||||
model,
|
||||
lmMessages: lmMessages as vscode.LanguageModelChatMessage[],
|
||||
lmTools,
|
||||
requestOptions,
|
||||
mergedTools
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates chat completion context for response formatting
|
||||
* @param body - The chat completion request
|
||||
* @param hasTools - Whether tools are present in the request
|
||||
* @returns Context object for response handling
|
||||
*/
|
||||
public createChatCompletionContext(
|
||||
body: ChatCompletionRequest,
|
||||
hasTools: boolean
|
||||
): ChatCompletionContext {
|
||||
return {
|
||||
requestId: `chatcmpl-${Math.random().toString(36).slice(2)}`,
|
||||
modelName: body.model || 'copilot',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
hasTools,
|
||||
isStreaming: body.stream !== false
|
||||
};
|
||||
}
|
||||
}
|
||||
39
src/services/request-processor.ts
Normal file
39
src/services/request-processor.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { ChatCompletionRequest, Tool } from '../messages';
|
||||
import type * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Validates and extracts tool configurations from request body
|
||||
* @param body - The parsed request body
|
||||
* @returns Combined tools array including converted deprecated functions
|
||||
*/
|
||||
export function extractAndMergeTools(body: ChatCompletionRequest): Tool[] {
|
||||
const tools = body.tools || [];
|
||||
|
||||
if (body.functions) {
|
||||
// Convert deprecated functions to tools format
|
||||
const convertedTools: Tool[] = body.functions.map(func => ({
|
||||
type: 'function' as const,
|
||||
function: func
|
||||
}));
|
||||
return [...tools, ...convertedTools];
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates VS Code Language Model request options from processed context
|
||||
* @param lmTools - Array of Language Model compatible tools
|
||||
* @returns Request options object for the Language Model API
|
||||
*/
|
||||
export function createLanguageModelRequestOptions(
|
||||
lmTools: vscode.LanguageModelChatTool[]
|
||||
): vscode.LanguageModelChatRequestOptions {
|
||||
const options: vscode.LanguageModelChatRequestOptions = {};
|
||||
|
||||
if (lmTools.length > 0) {
|
||||
options.tools = lmTools;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
158
src/services/response-formatter.ts
Normal file
158
src/services/response-formatter.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import type * as vscode from 'vscode';
|
||||
import type { ServerResponse } from 'http';
|
||||
import type {
|
||||
OpenAIResponse,
|
||||
OpenAIChoice,
|
||||
OpenAIMessage,
|
||||
OpenAIToolCall,
|
||||
ChatCompletionContext,
|
||||
ProcessedResponseData
|
||||
} from '../types/openai-types';
|
||||
import type { ChatCompletionRequest } from '../messages';
|
||||
import { writeJson } from '../http/utils';
|
||||
import { verbose } from '../log';
|
||||
|
||||
/**
|
||||
* Processes VS Code Language Model stream parts into structured data
|
||||
* @param response - The VS Code Language Model chat response
|
||||
* @returns Promise resolving to processed content and tool calls
|
||||
*/
|
||||
export async function processLanguageModelResponse(
|
||||
response: vscode.LanguageModelChatResponse
|
||||
): Promise<ProcessedResponseData> {
|
||||
let content = '';
|
||||
const toolCalls: OpenAIToolCall[] = [];
|
||||
|
||||
for await (const part of response.stream) {
|
||||
if (isToolCallPart(part)) {
|
||||
const toolCall: OpenAIToolCall = {
|
||||
id: part.callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: JSON.stringify(part.input)
|
||||
}
|
||||
};
|
||||
toolCalls.push(toolCall);
|
||||
} else if (isTextPart(part)) {
|
||||
content += extractTextContent(part);
|
||||
}
|
||||
}
|
||||
|
||||
const finishReason: OpenAIChoice['finish_reason'] = toolCalls.length > 0 ? 'tool_calls' : 'stop';
|
||||
|
||||
return {
|
||||
content,
|
||||
toolCalls,
|
||||
finishReason
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an OpenAI-compatible response message
|
||||
* @param data - The processed response data
|
||||
* @param requestBody - Original request body for backward compatibility
|
||||
* @returns OpenAI message object
|
||||
*/
|
||||
export function createOpenAIMessage(
|
||||
data: ProcessedResponseData,
|
||||
requestBody?: ChatCompletionRequest
|
||||
): OpenAIMessage {
|
||||
const baseMessage = {
|
||||
role: 'assistant' as const,
|
||||
content: data.toolCalls.length > 0 ? null : data.content,
|
||||
};
|
||||
|
||||
// Add tool_calls if present
|
||||
if (data.toolCalls.length > 0) {
|
||||
const messageWithTools = {
|
||||
...baseMessage,
|
||||
tool_calls: data.toolCalls,
|
||||
};
|
||||
|
||||
// For backward compatibility, also add function_call if there's exactly one tool call
|
||||
if (data.toolCalls.length === 1 && requestBody?.function_call !== undefined) {
|
||||
return {
|
||||
...messageWithTools,
|
||||
function_call: {
|
||||
name: data.toolCalls[0].function.name,
|
||||
arguments: data.toolCalls[0].function.arguments
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return messageWithTools;
|
||||
}
|
||||
|
||||
return baseMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a complete (non-streaming) OpenAI-compatible response
|
||||
* @param res - HTTP response object
|
||||
* @param context - Chat completion context
|
||||
* @param data - Processed response data
|
||||
* @param requestBody - Original request body
|
||||
*/
|
||||
export function sendCompletionResponse(
|
||||
res: ServerResponse,
|
||||
context: ChatCompletionContext,
|
||||
data: ProcessedResponseData,
|
||||
requestBody?: ChatCompletionRequest
|
||||
): void {
|
||||
const message = createOpenAIMessage(data, requestBody);
|
||||
|
||||
const responseObj: OpenAIResponse = {
|
||||
id: context.requestId,
|
||||
object: 'chat.completion',
|
||||
created: context.created,
|
||||
model: context.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message,
|
||||
finish_reason: data.finishReason,
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 0, // VS Code API doesn't provide token counts
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0
|
||||
}
|
||||
};
|
||||
|
||||
verbose(`Non-stream complete len=${data.content.length} tool_calls=${data.toolCalls.length}`);
|
||||
writeJson(res, 200, responseObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for VS Code LanguageModelToolCallPart
|
||||
*/
|
||||
function isToolCallPart(part: unknown): part is vscode.LanguageModelToolCallPart {
|
||||
return part !== null &&
|
||||
typeof part === 'object' &&
|
||||
'callId' in part &&
|
||||
'name' in part &&
|
||||
'input' in part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for text content parts
|
||||
*/
|
||||
function isTextPart(part: unknown): boolean {
|
||||
return typeof part === 'string' ||
|
||||
(part !== null && typeof part === 'object' && 'value' in part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from various part types
|
||||
*/
|
||||
function extractTextContent(part: unknown): string {
|
||||
if (typeof part === 'string') {
|
||||
return part;
|
||||
}
|
||||
|
||||
if (part !== null && typeof part === 'object' && 'value' in part) {
|
||||
return String((part as { value: unknown }).value) || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
190
src/services/streaming-handler.ts
Normal file
190
src/services/streaming-handler.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import type * as vscode from 'vscode';
|
||||
import type { ServerResponse } from 'http';
|
||||
import type {
|
||||
OpenAIResponse,
|
||||
OpenAIToolCall,
|
||||
ChatCompletionContext
|
||||
} from '../types/openai-types';
|
||||
import type { ChatCompletionRequest } from '../messages';
|
||||
import { verbose } from '../log';
|
||||
|
||||
/**
|
||||
* Handles Server-Sent Events streaming for OpenAI-compatible chat completions
|
||||
*/
|
||||
export class StreamingResponseHandler {
|
||||
private readonly response: ServerResponse;
|
||||
private readonly context: ChatCompletionContext;
|
||||
private readonly requestBody?: ChatCompletionRequest;
|
||||
|
||||
constructor(
|
||||
response: ServerResponse,
|
||||
context: ChatCompletionContext,
|
||||
requestBody?: ChatCompletionRequest
|
||||
) {
|
||||
this.response = response;
|
||||
this.context = context;
|
||||
this.requestBody = requestBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the SSE stream with proper headers
|
||||
*/
|
||||
public initializeStream(): void {
|
||||
this.response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
|
||||
verbose(`SSE start id=${this.context.requestId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the Language Model response stream and sends SSE chunks
|
||||
* @param languageModelResponse - VS Code Language Model response
|
||||
*/
|
||||
public async processAndStreamResponse(
|
||||
languageModelResponse: vscode.LanguageModelChatResponse
|
||||
): Promise<void> {
|
||||
const toolCalls: OpenAIToolCall[] = [];
|
||||
|
||||
for await (const part of languageModelResponse.stream) {
|
||||
if (this.isToolCallPart(part)) {
|
||||
const toolCall = this.createToolCallFromPart(part);
|
||||
toolCalls.push(toolCall);
|
||||
this.sendToolCallChunk(toolCall);
|
||||
} else if (this.isTextPart(part)) {
|
||||
const content = this.extractTextContent(part);
|
||||
if (content) {
|
||||
this.sendContentChunk(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.sendFinalChunk(toolCalls.length > 0 ? 'tool_calls' : 'stop');
|
||||
this.endStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a content delta chunk
|
||||
*/
|
||||
private sendContentChunk(content: string): void {
|
||||
const chunkResponse: OpenAIResponse = {
|
||||
id: this.context.requestId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: this.context.created,
|
||||
model: this.context.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
|
||||
this.writeSSEData(chunkResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a tool call chunk
|
||||
*/
|
||||
private sendToolCallChunk(toolCall: OpenAIToolCall): void {
|
||||
const chunkResponse: OpenAIResponse = {
|
||||
id: this.context.requestId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: this.context.created,
|
||||
model: this.context.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [toolCall]
|
||||
},
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
|
||||
this.writeSSEData(chunkResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the final completion chunk with finish reason
|
||||
*/
|
||||
private sendFinalChunk(finishReason: 'stop' | 'tool_calls'): void {
|
||||
const finalChunkResponse: OpenAIResponse = {
|
||||
id: this.context.requestId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: this.context.created,
|
||||
model: this.context.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: finishReason
|
||||
}]
|
||||
};
|
||||
|
||||
this.writeSSEData(finalChunkResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the SSE stream
|
||||
*/
|
||||
private endStream(): void {
|
||||
verbose(`SSE end id=${this.context.requestId}`);
|
||||
this.response.write('data: [DONE]\n\n');
|
||||
this.response.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes data to the SSE stream
|
||||
*/
|
||||
private writeSSEData(data: OpenAIResponse): void {
|
||||
this.response.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an OpenAI tool call from VS Code Language Model part
|
||||
*/
|
||||
private createToolCallFromPart(part: vscode.LanguageModelToolCallPart): OpenAIToolCall {
|
||||
return {
|
||||
id: part.callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: JSON.stringify(part.input)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for VS Code LanguageModelToolCallPart
|
||||
*/
|
||||
private isToolCallPart(part: unknown): part is vscode.LanguageModelToolCallPart {
|
||||
return part !== null &&
|
||||
typeof part === 'object' &&
|
||||
'callId' in part &&
|
||||
'name' in part &&
|
||||
'input' in part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for text content parts
|
||||
*/
|
||||
private isTextPart(part: unknown): boolean {
|
||||
return typeof part === 'string' ||
|
||||
(part !== null && typeof part === 'object' && 'value' in part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from various part types
|
||||
*/
|
||||
private extractTextContent(part: unknown): string {
|
||||
if (typeof part === 'string') {
|
||||
return part;
|
||||
}
|
||||
|
||||
if (part !== null && typeof part === 'object' && 'value' in part) {
|
||||
return String((part as { value: unknown }).value) || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
81
src/types/openai-types.ts
Normal file
81
src/types/openai-types.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type * as vscode from 'vscode';
|
||||
import type { Tool } from '../messages';
|
||||
|
||||
/**
|
||||
* OpenAI API compatible types for request and response handling
|
||||
*/
|
||||
|
||||
export interface OpenAIToolCall {
|
||||
readonly id: string;
|
||||
readonly type: 'function';
|
||||
readonly function: {
|
||||
readonly name: string;
|
||||
readonly arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAIMessage {
|
||||
readonly role: 'assistant';
|
||||
readonly content: string | null;
|
||||
readonly tool_calls?: OpenAIToolCall[];
|
||||
readonly function_call?: {
|
||||
readonly name: string;
|
||||
readonly arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAIChoice {
|
||||
readonly index: number;
|
||||
readonly message?: OpenAIMessage;
|
||||
readonly delta?: Partial<OpenAIMessage>;
|
||||
readonly finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call' | null;
|
||||
}
|
||||
|
||||
export interface OpenAIResponse {
|
||||
readonly id: string;
|
||||
readonly object: 'chat.completion' | 'chat.completion.chunk';
|
||||
readonly created: number;
|
||||
readonly model: string;
|
||||
readonly choices: OpenAIChoice[];
|
||||
readonly usage?: {
|
||||
readonly prompt_tokens: number;
|
||||
readonly completion_tokens: number;
|
||||
readonly total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatCompletionContext {
|
||||
readonly requestId: string;
|
||||
readonly modelName: string;
|
||||
readonly created: number;
|
||||
readonly hasTools: boolean;
|
||||
readonly isStreaming: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessedResponseData {
|
||||
readonly content: string;
|
||||
readonly toolCalls: OpenAIToolCall[];
|
||||
readonly finishReason: OpenAIChoice['finish_reason'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the request model is available and properly configured
|
||||
*/
|
||||
export interface ModelValidationResult {
|
||||
readonly isValid: boolean;
|
||||
readonly statusCode?: number;
|
||||
readonly errorType?: string;
|
||||
readonly errorCode?: string;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated request processing context for chat completions
|
||||
*/
|
||||
export interface RequestProcessingContext {
|
||||
readonly model: vscode.LanguageModelChat;
|
||||
readonly lmMessages: vscode.LanguageModelChatMessage[];
|
||||
readonly lmTools: vscode.LanguageModelChatTool[];
|
||||
readonly requestOptions: vscode.LanguageModelChatRequestOptions;
|
||||
readonly mergedTools: Tool[];
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node", "vscode"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue