From 8ec0b2cf0846fa9f82e5c25d61aa3c91e853e1a0 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:11:20 -0800 Subject: [PATCH] Add VS Code LM API --- package-lock.json | 12 +- package.json | 21 +- src/api/index.ts | 7 + src/api/providers/vscode-lm.ts | 547 ++++++++++++++++++ src/api/transform/vscode-lm-format.ts | 200 +++++++ src/core/webview/ClineProvider.ts | 22 + src/integrations/terminal/TerminalManager.ts | 16 +- src/shared/ExtensionMessage.ts | 3 + src/shared/WebviewMessage.ts | 1 + src/shared/api.ts | 2 + src/shared/vsCodeSelectorUtils.ts | 7 + webview-ui/src/components/chat/TaskHeader.tsx | 1 + .../src/components/settings/ApiOptions.tsx | 88 ++- .../src/context/ExtensionStateContext.tsx | 1 + webview-ui/src/utils/validate.ts | 5 + 15 files changed, 916 insertions(+), 17 deletions(-) create mode 100644 src/api/providers/vscode-lm.ts create mode 100644 src/api/transform/vscode-lm-format.ts create mode 100644 src/shared/vsCodeSelectorUtils.ts diff --git a/package-lock.json b/package-lock.json index b1de717b85..eb7141005b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-dev", - "version": "3.1.8", + "version": "3.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-dev", - "version": "3.1.8", + "version": "3.1.11", "license": "Apache-2.0", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", @@ -53,7 +53,7 @@ "@types/mocha": "^10.0.7", "@types/node": "20.x", "@types/should": "^11.2.0", - "@types/vscode": "^1.84.0", + "@types/vscode": "^1.96.0", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.11.0", "@vscode/test-cli": "^0.0.9", @@ -4641,9 +4641,9 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.84.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.84.0.tgz", - "integrity": "sha512-lCGOSrhT3cL+foUEqc8G1PVZxoDbiMmxgnUZZTEnHF4mC47eKAUtBGAuMLY6o6Ua8PAuNCoKXbqPmJd1JYnQfg==", + "version": "1.96.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.96.0.tgz", + "integrity": "sha512-qvZbSZo+K4ZYmmDuaodMbAa67Pl6VDQzLKFka6rq+3WUTY4Kro7Bwoi0CuZLO/wema0ygcmpwow7zZfPJTs5jg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index ce4278f676..8757be5c21 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,25 @@ "when": "view == claude-dev.SidebarProvider" } ] + }, + "configuration": { + "title": "Cline", + "properties": { + "cline.vsCodeLmModelSelector": { + "type": "object", + "properties": { + "vendor": { + "type": "string", + "description": "The vendor of the language model (e.g. copilot)" + }, + "family": { + "type": "string", + "description": "The family of the language model (e.g. gpt-4)" + } + }, + "description": "Settings for VSCode Language Model API" + } + } } }, "scripts": { @@ -152,7 +171,7 @@ "@types/mocha": "^10.0.7", "@types/node": "20.x", "@types/should": "^11.2.0", - "@types/vscode": "^1.84.0", + "@types/vscode": "^1.96.0", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.11.0", "@vscode/test-cli": "^0.0.9", diff --git a/src/api/index.ts b/src/api/index.ts index 061b61b8be..f200a91b21 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -12,6 +12,7 @@ import { OpenAiNativeHandler } from "./providers/openai-native" import { ApiStream } from "./transform/stream" import { DeepSeekHandler } from "./providers/deepseek" import { MistralHandler } from "./providers/mistral" +import { VsCodeLmHandler } from "./providers/vscode-lm" export interface ApiHandler { createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], modelType?: ModelType): ApiStream @@ -19,6 +20,10 @@ export interface ApiHandler { getAdvisorModel?(): { id: string; info: ModelInfo } } +export interface SingleCompletionHandler { + completePrompt(prompt: string): Promise +} + export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { const { apiProvider, ...options } = configuration switch (apiProvider) { @@ -44,6 +49,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { return new DeepSeekHandler(options) case "mistral": return new MistralHandler(options) + case "vscode-lm": + return new VsCodeLmHandler(options) default: return new AnthropicHandler(options) } diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts new file mode 100644 index 0000000000..8c138a9102 --- /dev/null +++ b/src/api/providers/vscode-lm.ts @@ -0,0 +1,547 @@ +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 + // Normalize line breaks + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + + // Remove ANSI escape sequences + .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "") // Full set of ANSI sequences + .replace(/\x9B[0-?]*[ -/]*[@-~]/g, "") // CSI sequences + + // Remove terminal title setting sequences and other OSC sequences + .replace(/\x1B\][0-9;]*(?:\x07|\x1B\\)/g, "") + + // Remove control characters + .replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F]/g, "") + + // Remove VS Code escape sequences + .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 + + // Remove Windows paths and service information + .replace(/^(?:PS )?[A-Z]:\\[^\n]*$/gm, "") + .replace(/^;?Cwd=.*$/gm, "") + + // Clean escaped sequences + .replace(/\\x[0-9a-fA-F]{2}/g, "") + .replace(/\\u[0-9a-fA-F]{4}/g, "") + + // Final cleanup + .replace(/\n{3,}/g, "\n\n") // Remove multiple empty lines + .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 + } + } +} diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts new file mode 100644 index 0000000000..acec3656e1 --- /dev/null +++ b/src/api/transform/vscode-lm-format.ts @@ -0,0 +1,200 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import * as vscode from "vscode" + +/** + * Safely converts a value into a plain object. + */ +function asObjectSafe(value: any): object { + // Handle null/undefined + if (!value) { + return {} + } + + try { + // Handle strings that might be JSON + if (typeof value === "string") { + return JSON.parse(value) + } + + // Handle pre-existing objects + if (typeof value === "object") { + return Object.assign({}, value) + } + + return {} + } catch (error) { + console.warn("Cline : Failed to parse object:", error) + return {} + } +} + +export function convertToVsCodeLmMessages( + anthropicMessages: Anthropic.Messages.MessageParam[], +): vscode.LanguageModelChatMessage[] { + const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [] + + for (const anthropicMessage of anthropicMessages) { + // Handle simple string messages + if (typeof anthropicMessage.content === "string") { + vsCodeLmMessages.push( + anthropicMessage.role === "assistant" + ? vscode.LanguageModelChatMessage.Assistant(anthropicMessage.content) + : vscode.LanguageModelChatMessage.User(anthropicMessage.content), + ) + continue + } + + // Handle complex message structures + switch (anthropicMessage.role) { + case "user": { + const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ + nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] + toolMessages: Anthropic.ToolResultBlockParam[] + }>( + (acc, part) => { + if (part.type === "tool_result") { + acc.toolMessages.push(part) + } else if (part.type === "text" || part.type === "image") { + acc.nonToolMessages.push(part) + } + return acc + }, + { nonToolMessages: [], toolMessages: [] }, + ) + + // Process tool messages first then non-tool messages + const contentParts = [ + // Convert tool messages to ToolResultParts + ...toolMessages.map((toolMessage) => { + // Process tool result content into TextParts + const toolContentParts: vscode.LanguageModelTextPart[] = + typeof toolMessage.content === "string" + ? [new vscode.LanguageModelTextPart(toolMessage.content)] + : (toolMessage.content?.map((part) => { + if (part.type === "image") { + return new vscode.LanguageModelTextPart( + `[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`, + ) + } + return new vscode.LanguageModelTextPart(part.text) + }) ?? [new vscode.LanguageModelTextPart("")]) + + return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts) + }), + + // Convert non-tool messages to TextParts after tool messages + ...nonToolMessages.map((part) => { + if (part.type === "image") { + return new vscode.LanguageModelTextPart( + `[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`, + ) + } + return new vscode.LanguageModelTextPart(part.text) + }), + ] + + // Add single user message with all content parts + vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts)) + break + } + + case "assistant": { + const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ + nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] + toolMessages: Anthropic.ToolUseBlockParam[] + }>( + (acc, part) => { + if (part.type === "tool_use") { + acc.toolMessages.push(part) + } else if (part.type === "text" || part.type === "image") { + acc.nonToolMessages.push(part) + } + return acc + }, + { nonToolMessages: [], toolMessages: [] }, + ) + + // Process tool messages first then non-tool messages + const contentParts = [ + // Convert tool messages to ToolCallParts first + ...toolMessages.map( + (toolMessage) => + new vscode.LanguageModelToolCallPart( + toolMessage.id, + toolMessage.name, + asObjectSafe(toolMessage.input), + ), + ), + + // Convert non-tool messages to TextParts after tool messages + ...nonToolMessages.map((part) => { + if (part.type === "image") { + return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]") + } + return new vscode.LanguageModelTextPart(part.text) + }), + ] + + // Add the assistant message to the list of messages + vsCodeLmMessages.push(vscode.LanguageModelChatMessage.Assistant(contentParts)) + break + } + } + } + + return vsCodeLmMessages +} + +export function convertToAnthropicRole(vsCodeLmMessageRole: vscode.LanguageModelChatMessageRole): string | null { + switch (vsCodeLmMessageRole) { + case vscode.LanguageModelChatMessageRole.Assistant: + return "assistant" + case vscode.LanguageModelChatMessageRole.User: + return "user" + default: + return null + } +} + +export async function convertToAnthropicMessage( + vsCodeLmMessage: vscode.LanguageModelChatMessage, +): Promise { + const anthropicRole: string | null = convertToAnthropicRole(vsCodeLmMessage.role) + if (anthropicRole !== "assistant") { + throw new Error("Cline : Only assistant messages are supported.") + } + + return { + id: crypto.randomUUID(), + type: "message", + model: "vscode-lm", + role: anthropicRole, + content: vsCodeLmMessage.content + .map((part): Anthropic.ContentBlock | null => { + if (part instanceof vscode.LanguageModelTextPart) { + return { + type: "text", + text: part.value, + } + } + + if (part instanceof vscode.LanguageModelToolCallPart) { + return { + type: "tool_use", + id: part.callId || crypto.randomUUID(), + name: part.name, + input: asObjectSafe(part.input), + } + } + + return null + }) + .filter((part): part is Anthropic.ContentBlock => part !== null), + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 105f51091e..616c06e4de 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -69,6 +69,7 @@ type GlobalStateKey = | "autoApprovalSettings" | "browserSettings" | "chatSettings" + | "vsCodeLmModelSelector" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -424,6 +425,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { openRouterModelInfo, openRouterAdvisorModelId, openRouterAdvisorModelInfo, + vsCodeLmModelSelector, } = message.apiConfiguration await this.updateGlobalState("apiProvider", apiProvider) await this.updateGlobalState("apiModelId", apiModelId) @@ -454,6 +456,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo) await this.updateGlobalState("openRouterAdvisorModelId", openRouterAdvisorModelId) await this.updateGlobalState("openRouterAdvisorModelInfo", openRouterAdvisorModelInfo) + await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector) if (this.cline) { this.cline.api = buildApiHandler(message.apiConfiguration) } @@ -547,6 +550,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioModels, }) break + case "requestVsCodeLmModels": + const vsCodeLmModels = await this.getVsCodeLmModels() + this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels }) + break case "refreshOpenRouterModels": await this.refreshOpenRouterModels() break @@ -674,6 +681,18 @@ export class ClineProvider implements vscode.WebviewViewProvider { return settingsDir } + // VSCode LM API + + private async getVsCodeLmModels() { + try { + const models = await vscode.lm.selectChatModels({}) + return models || [] + } catch (error) { + console.error("Error fetching VS Code LM models:", error) + return [] + } + } + // Ollama async getOllamaModels(baseUrl?: string) { @@ -1090,6 +1109,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { autoApprovalSettings, browserSettings, chatSettings, + vsCodeLmModelSelector, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1126,6 +1146,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("autoApprovalSettings") as Promise, this.getGlobalState("browserSettings") as Promise, this.getGlobalState("chatSettings") as Promise, + this.getGlobalState("vsCodeLmModelSelector") as Promise, ]) let apiProvider: ApiProvider @@ -1173,6 +1194,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { openRouterModelInfo, openRouterAdvisorModelId, openRouterAdvisorModelInfo, + vsCodeLmModelSelector, }, lastShownAnnouncementId, customInstructions, diff --git a/src/integrations/terminal/TerminalManager.ts b/src/integrations/terminal/TerminalManager.ts index 81e91ab6b8..2de5be3a6f 100644 --- a/src/integrations/terminal/TerminalManager.ts +++ b/src/integrations/terminal/TerminalManager.ts @@ -71,14 +71,14 @@ This approach allows us to leverage advanced features when available while ensur */ declare module "vscode" { // https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L7442 - interface Terminal { - shellIntegration?: { - cwd?: vscode.Uri - executeCommand?: (command: string) => { - read: () => AsyncIterable - } - } - } + // interface Terminal { + // shellIntegration?: { + // cwd?: vscode.Uri + // executeCommand?: (command: string) => { + // read: () => AsyncIterable + // } + // } + // } // https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L10794 interface Window { onDidStartTerminalShellExecution?: ( diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 3f6670b4f2..06b28e8823 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -23,6 +23,8 @@ export interface ExtensionMessage { | "mcpServers" | "relinquishControl" | "openAdvisorModelSettings" + | "vsCodeLmModels" + | "requestVsCodeLmModels" text?: string action?: "chatButtonClicked" | "mcpButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" | "didBecomeVisible" invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" @@ -30,6 +32,7 @@ export interface ExtensionMessage { images?: string[] ollamaModels?: string[] lmStudioModels?: string[] + vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[] filePaths?: string[] partialMessage?: ClineMessage openRouterModels?: Record diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index b18738316b..897dabbb86 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -34,6 +34,7 @@ export interface WebviewMessage { | "checkpointRestore" | "taskCompletionViewChanges" | "openAdvisorModelSettings" + | "requestVsCodeLmModels" // | "relaunchChromeDebugMode" text?: string askResponse?: ClineAskResponse diff --git a/src/shared/api.ts b/src/shared/api.ts index 013a063777..139c5e0544 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -10,6 +10,7 @@ export type ApiProvider = | "openai-native" | "deepseek" | "mistral" + | "vscode-lm" export interface ApiHandlerOptions { apiModelId?: string @@ -40,6 +41,7 @@ export interface ApiHandlerOptions { deepSeekApiKey?: string mistralApiKey?: string azureApiVersion?: string + vsCodeLmModelSelector?: any } export type ApiConfiguration = ApiHandlerOptions & { diff --git a/src/shared/vsCodeSelectorUtils.ts b/src/shared/vsCodeSelectorUtils.ts new file mode 100644 index 0000000000..620fccccd8 --- /dev/null +++ b/src/shared/vsCodeSelectorUtils.ts @@ -0,0 +1,7 @@ +import { LanguageModelChatSelector } from "vscode" + +export const SELECTOR_SEPARATOR = "/" + +export function stringifyVsCodeLmModelSelector(selector: LanguageModelChatSelector): string { + return [selector.vendor, selector.family, selector.version, selector.id].filter(Boolean).join(SELECTOR_SEPARATOR) +} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 253ecef5df..cd7280737b 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -96,6 +96,7 @@ const TaskHeader: React.FC = ({ const isCostAvailable = useMemo(() => { return ( apiConfiguration?.apiProvider !== "openai" && + apiConfiguration?.apiProvider !== "vscode-lm" && apiConfiguration?.apiProvider !== "ollama" && apiConfiguration?.apiProvider !== "lmstudio" && apiConfiguration?.apiProvider !== "gemini" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 2588bd10b9..0aace65eb4 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -42,6 +42,7 @@ import { vscode } from "../../utils/vscode" import VSCodeButtonLink from "../common/VSCodeButtonLink" import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker" import styled from "styled-components" +import * as vscodemodels from "vscode" interface ApiOptionsProps { showModelOptions: boolean @@ -97,6 +98,7 @@ const ApiOptions = ({ const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) + const [vsCodeLmModels, setVsCodeLmModels] = useState([]) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) @@ -125,14 +127,19 @@ const ApiOptions = ({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl, }) + } else if (selectedProvider === "vscode-lm") { + vscode.postMessage({ type: "requestVsCodeLmModels" }) } }, [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl]) useEffect(() => { - if (selectedProvider === "ollama" || selectedProvider === "lmstudio") { + if (selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm") { requestLocalModels() } }, [selectedProvider, requestLocalModels]) - useInterval(requestLocalModels, selectedProvider === "ollama" || selectedProvider === "lmstudio" ? 2000 : null) + useInterval( + requestLocalModels, + selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm" ? 2000 : null, + ) const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -140,6 +147,8 @@ const ApiOptions = ({ setOllamaModels(message.ollamaModels) } else if (message.type === "lmStudioModels" && message.lmStudioModels) { setLmStudioModels(message.lmStudioModels) + } else if (message.type === "vsCodeLmModels" && message.vsCodeLmModels) { + setVsCodeLmModels(message.vsCodeLmModels) } }, []) useEvent("message", handleMessage) @@ -204,6 +213,7 @@ const ApiOptions = ({ AWS Bedrock OpenAI OpenAI Compatible + VS Code LM API LM Studio Ollama @@ -630,6 +640,68 @@ const ApiOptions = ({ )} + {selectedProvider === "vscode-lm" && ( +
+
+ + {vsCodeLmModels.length > 0 ? ( + { + const value = (e.target as HTMLInputElement).value + if (!value) { + return + } + const [vendor, family] = value.split("/") + handleInputChange("vsCodeLmModelSelector")({ + target: { + value: { vendor, family }, + }, + }) + }} + style={{ width: "100%" }}> + Select a model... + {vsCodeLmModels.map((model) => ( + + {model.vendor} - {model.family} + + ))} + + ) : ( +

+ The VS Code Language Model API allows you to run models provided by other VS Code extensions + (including but not limited to GitHub Copilot). The easiest way to get started is to install the + Copilot extension from the VS Marketplace and enabling Claude 3.5 Sonnet. +

+ )} + +

+ Note: This is a very experimental integration and may not work as expected. +

+
+
+ )} + {selectedProvider === "lmstudio" && (
@@ -1089,6 +1162,17 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration): selectedModelId: apiConfiguration?.lmStudioModelId || "", selectedModelInfo: openAiModelInfoSaneDefaults, } + case "vscode-lm": + return { + selectedProvider: provider, + selectedModelId: apiConfiguration?.vsCodeLmModelSelector + ? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}` + : "", + selectedModelInfo: { + ...openAiModelInfoSaneDefaults, + supportsImages: false, // VSCode LM API currently doesn't support images + }, + } default: return getProviderData(anthropicModels, anthropicDefaultModelId) } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 425b35db88..69e67f1a3d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -72,6 +72,7 @@ export const ExtensionStateContextProvider: React.FC<{ config.openAiNativeApiKey, config.deepSeekApiKey, config.mistralApiKey, + config.vsCodeLmModelSelector, ].some((key) => key !== undefined) : false setShowWelcome(!hasKey) diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 302c45d6a2..e0b06429e1 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -58,6 +58,11 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s return "You must provide a valid model ID." } break + case "vscode-lm": + if (!apiConfiguration.vsCodeLmModelSelector) { + return "You must provide a valid model selector." + } + break } } return undefined