import { Anthropic } from "@anthropic-ai/sdk"; import * as vscode from 'vscode'; import { ApiHandler, SingleCompletionHandler } from "../"; import { calculateApiCost } from "../../utils/cost"; import { ApiStream } from "../transform/stream"; import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format"; import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"; import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"; /** * Handles interaction with VS Code's Language Model API for chat-based operations. * This handler implements the ApiHandler interface to provide VS Code LM specific functionality. * * @implements {ApiHandler} * * @remarks * The handler manages a VS Code language model chat client and provides methods to: * - Create and manage chat client instances * - Stream messages using VS Code's Language Model API * - Retrieve model information * * @example * ```typescript * const options = { * vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4" } * }; * const handler = new VsCodeLmHandler(options); * * // Stream a conversation * const systemPrompt = "You are a helpful assistant"; * const messages = [{ role: "user", content: "Hello!" }]; * for await (const chunk of handler.createMessage(systemPrompt, messages)) { * console.log(chunk); * } * ``` */ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions; private client: vscode.LanguageModelChat | null; private disposable: vscode.Disposable | null; private currentRequestCancellation: vscode.CancellationTokenSource | null; constructor(options: ApiHandlerOptions) { this.options = options; this.client = null; this.disposable = null; this.currentRequestCancellation = null; try { // Listen for model changes and reset client this.disposable = vscode.workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration('lm')) { try { this.client = null; this.ensureCleanState(); } catch (error) { console.error('Error during configuration change cleanup:', error); } } }); } catch (error) { // Ensure cleanup if constructor fails this.dispose(); throw new Error( `Cline : Failed to initialize handler: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Creates a language model chat client based on the provided selector. * * @param selector - Selector criteria to filter language model chat instances * @returns Promise resolving to the first matching language model chat instance * @throws Error when no matching models are found with the given selector * * @example * const selector = { vendor: "copilot", family: "gpt-4o" }; * const chatClient = await createClient(selector); */ async createClient(selector: vscode.LanguageModelChatSelector): Promise { try { const models = await vscode.lm.selectChatModels(selector); // Use first available model or create a minimal model object if (models && Array.isArray(models) && models.length > 0) { return models[0]; } // Create a minimal model if no models are available return { id: 'default-lm', name: 'Default Language Model', vendor: 'vscode', family: 'lm', version: '1.0', maxInputTokens: 8192, sendRequest: async (messages, options, token) => { // Provide a minimal implementation return { stream: (async function* () { yield new vscode.LanguageModelTextPart( "Language model functionality is limited. Please check VS Code configuration." ); })(), text: (async function* () { yield "Language model functionality is limited. Please check VS Code configuration."; })() }; }, countTokens: async () => 0 }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Cline : Failed to select model: ${errorMessage}`); } } /** * Creates and streams a message using the VS Code Language Model API. * * @param systemPrompt - The system prompt to initialize the conversation context * @param messages - An array of message parameters following the Anthropic message format * * @yields {ApiStream} An async generator that yields either text chunks or tool calls from the model response * * @throws {Error} When vsCodeLmModelSelector option is not provided * @throws {Error} When the response stream encounters an error * * @remarks * This method handles the initialization of the VS Code LM client if not already created, * converts the messages to VS Code LM format, and streams the response chunks. * Tool calls handling is currently a work in progress. */ dispose(): void { if (this.disposable) { this.disposable.dispose(); } if (this.currentRequestCancellation) { this.currentRequestCancellation.cancel(); this.currentRequestCancellation.dispose(); } } private async countTokens(text: string | vscode.LanguageModelChatMessage): Promise { // Check for required dependencies if (!this.client) { console.warn('Cline : No client available for token counting'); return 0; } if (!this.currentRequestCancellation) { console.warn('Cline : No cancellation token available for token counting'); return 0; } // Validate input if (!text) { console.debug('Cline : Empty text provided for token counting'); return 0; } try { // Handle different input types let tokenCount: number; if (typeof text === 'string') { tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token); } else if (text instanceof vscode.LanguageModelChatMessage) { // For chat messages, ensure we have content if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) { console.debug('Cline : Empty chat message content'); return 0; } tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token); } else { console.warn('Cline : Invalid input type for token counting'); return 0; } // Validate the result if (typeof tokenCount !== 'number') { console.warn('Cline : Non-numeric token count received:', tokenCount); return 0; } if (tokenCount < 0) { console.warn('Cline : Negative token count received:', tokenCount); return 0; } return tokenCount; } catch (error) { // Handle specific error types if (error instanceof vscode.CancellationError) { console.debug('Cline : Token counting cancelled by user'); return 0; } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.warn('Cline : Token counting failed:', errorMessage); // Log additional error details if available if (error instanceof Error && error.stack) { console.debug('Token counting error stack:', error.stack); } return 0; // Fallback to prevent stream interruption } } private async calculateTotalInputTokens(systemPrompt: string, vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise { const systemTokens: number = await this.countTokens(systemPrompt); const messageTokens: number[] = await Promise.all( vsCodeLmMessages.map(msg => this.countTokens(msg)) ); return systemTokens + messageTokens.reduce( (sum: number, tokens: number): number => sum + tokens, 0 ); } private ensureCleanState(): void { if (this.currentRequestCancellation) { this.currentRequestCancellation.cancel(); this.currentRequestCancellation.dispose(); this.currentRequestCancellation = null; } } private async getClient(): Promise { if (!this.client) { console.debug('Cline : Getting client with options:', { vsCodeLmModelSelector: this.options.vsCodeLmModelSelector, hasOptions: !!this.options, selectorKeys: this.options.vsCodeLmModelSelector ? Object.keys(this.options.vsCodeLmModelSelector) : [] }); try { // Use default empty selector if none provided to get all available models const selector = this.options?.vsCodeLmModelSelector || {}; console.debug('Cline : Creating client with selector:', selector); this.client = await this.createClient(selector); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Cline : Client creation failed:', message); throw new Error(`Cline : Failed to create client: ${message}`); } } return this.client; } private cleanTerminalOutput(text: string): string { if (!text) { return ''; } return text // Нормализуем переносы строк .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') // Удаляем ANSI escape sequences .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') // Полный набор ANSI sequences .replace(/\x9B[0-?]*[ -/]*[@-~]/g, '') // CSI sequences // Удаляем последовательности установки заголовка терминала и прочие OSC sequences .replace(/\x1B\][0-9;]*(?:\x07|\x1B\\)/g, '') // Удаляем управляющие символы .replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F]/g, '') // Удаляем escape-последовательности VS Code .replace(/\x1B[PD].*?\x1B\\/g, '') // DCS sequences .replace(/\x1B_.*?\x1B\\/g, '') // APC sequences .replace(/\x1B\^.*?\x1B\\/g, '') // PM sequences .replace(/\x1B\[[\d;]*[HfABCDEFGJKST]/g, '') // Cursor movement and clear screen // Удаляем пути Windows и служебную информацию .replace(/^(?:PS )?[A-Z]:\\[^\n]*$/mg, '') .replace(/^;?Cwd=.*$/mg, '') // Очищаем экранированные последовательности .replace(/\\x[0-9a-fA-F]{2}/g, '') .replace(/\\u[0-9a-fA-F]{4}/g, '') // Финальная очистка .replace(/\n{3,}/g, '\n\n') // Убираем множественные пустые строки .trim(); } private cleanMessageContent(content: any): any { if (!content) { return content; } if (typeof content === 'string') { return this.cleanTerminalOutput(content); } if (Array.isArray(content)) { return content.map(item => this.cleanMessageContent(item)); } if (typeof content === 'object') { const cleaned: any = {}; for (const [key, value] of Object.entries(content)) { cleaned[key] = this.cleanMessageContent(value); } return cleaned; } return content; } async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { // Ensure clean state before starting a new request this.ensureCleanState(); const client: vscode.LanguageModelChat = await this.getClient(); // Clean system prompt and messages const cleanedSystemPrompt = this.cleanTerminalOutput(systemPrompt); const cleanedMessages = messages.map(msg => ({ ...msg, content: this.cleanMessageContent(msg.content) })); // Convert Anthropic messages to VS Code LM messages const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [ vscode.LanguageModelChatMessage.Assistant(cleanedSystemPrompt), ...convertToVsCodeLmMessages(cleanedMessages), ]; // Initialize cancellation token for the request this.currentRequestCancellation = new vscode.CancellationTokenSource(); // Calculate input tokens before starting the stream const totalInputTokens: number = await this.calculateTotalInputTokens(systemPrompt, vsCodeLmMessages); // Accumulate the text and count at the end of the stream to reduce token counting overhead. let accumulatedText: string = ''; try { // Create the response stream with minimal required options const requestOptions: vscode.LanguageModelChatRequestOptions = { justification: `Cline would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.` }; // Note: Tool support is currently provided by the VSCode Language Model API directly // Extensions can register tools using vscode.lm.registerTool() const response: vscode.LanguageModelChatResponse = await client.sendRequest( vsCodeLmMessages, requestOptions, this.currentRequestCancellation.token ); // Consume the stream and handle both text and tool call chunks for await (const chunk of response.stream) { if (chunk instanceof vscode.LanguageModelTextPart) { // Validate text part value if (typeof chunk.value !== 'string') { console.warn('Cline : Invalid text part value received:', chunk.value); continue; } accumulatedText += chunk.value; yield { type: "text", text: chunk.value, }; } else if (chunk instanceof vscode.LanguageModelToolCallPart) { try { // Validate tool call parameters if (!chunk.name || typeof chunk.name !== 'string') { console.warn('Cline : Invalid tool name received:', chunk.name); continue; } if (!chunk.callId || typeof chunk.callId !== 'string') { console.warn('Cline : Invalid tool callId received:', chunk.callId); continue; } // Ensure input is a valid object if (!chunk.input || typeof chunk.input !== 'object') { console.warn('Cline : Invalid tool input received:', chunk.input); continue; } // Convert tool calls to text format with proper error handling const toolCall = { type: "tool_call", name: chunk.name, arguments: chunk.input, callId: chunk.callId }; const toolCallText = JSON.stringify(toolCall); accumulatedText += toolCallText; // Log tool call for debugging console.debug('Cline : Processing tool call:', { name: chunk.name, callId: chunk.callId, inputSize: JSON.stringify(chunk.input).length }); yield { type: "text", text: toolCallText, }; } catch (error) { console.error('Cline : Failed to process tool call:', error); // Continue processing other chunks even if one fails continue; } } else { console.warn('Cline : Unknown chunk type received:', chunk); } } // Count tokens in the accumulated text after stream completion const totalOutputTokens: number = await this.countTokens(accumulatedText); // Report final usage after stream completion yield { type: "usage", inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalCost: calculateApiCost( this.getModel().info, totalInputTokens, totalOutputTokens ) }; } catch (error: unknown) { this.ensureCleanState(); if (error instanceof vscode.CancellationError) { throw new Error("Cline : Request cancelled by user"); } if (error instanceof Error) { console.error('Cline : Stream error details:', { message: error.message, stack: error.stack, name: error.name }); // Return original error if it's already an Error instance throw error; } else if (typeof error === 'object' && error !== null) { // Handle error-like objects const errorDetails = JSON.stringify(error, null, 2); console.error('Cline : Stream error object:', errorDetails); throw new Error(`Cline : Response stream error: ${errorDetails}`); } else { // Fallback for unknown error types const errorMessage = String(error); console.error('Cline : Unknown stream error:', errorMessage); throw new Error(`Cline : Response stream error: ${errorMessage}`); } } } // Return model information based on the current client state getModel(): { id: string; info: ModelInfo; } { if (this.client) { // Validate client properties const requiredProps = { id: this.client.id, vendor: this.client.vendor, family: this.client.family, version: this.client.version, maxInputTokens: this.client.maxInputTokens }; // Log any missing properties for debugging for (const [prop, value] of Object.entries(requiredProps)) { if (!value && value !== 0) { console.warn(`Cline : Client missing ${prop} property`); } } // Construct model ID using available information const modelParts = [ this.client.vendor, this.client.family, this.client.version ].filter(Boolean); const modelId = this.client.id || modelParts.join(SELECTOR_SEPARATOR); // Build model info with conservative defaults for missing values const modelInfo: ModelInfo = { maxTokens: -1, // Unlimited tokens by default contextWindow: typeof this.client.maxInputTokens === 'number' ? Math.max(0, this.client.maxInputTokens) : openAiModelInfoSaneDefaults.contextWindow, supportsImages: false, // VSCode Language Model API currently doesn't support image inputs supportsPromptCache: true, inputPrice: 0, outputPrice: 0, description: `VSCode Language Model: ${modelId}` }; return { id: modelId, info: modelInfo }; } // Fallback when no client is available const fallbackId = this.options.vsCodeLmModelSelector ? stringifyVsCodeLmModelSelector(this.options.vsCodeLmModelSelector) : "vscode-lm"; console.debug('Cline : No client available, using fallback model info'); return { id: fallbackId, info: { ...openAiModelInfoSaneDefaults, description: `VSCode Language Model (Fallback): ${fallbackId}` } }; } async completePrompt(prompt: string): Promise { try { const client = await this.getClient(); const response = await client.sendRequest([vscode.LanguageModelChatMessage.User(prompt)], {}, new vscode.CancellationTokenSource().token); let result = ""; for await (const chunk of response.stream) { if (chunk instanceof vscode.LanguageModelTextPart) { result += chunk.value; } } return result; } catch (error) { if (error instanceof Error) { throw new Error(`VSCode LM completion error: ${error.message}`) } throw error } } }