mirror of
https://github.com/cline/cline.git
synced 2026-10-29 17:42:34 +08:00
feat(vscode): add the VS Code Language Model (vscode-lm) provider
Run Cline inference through the VS Code Language Model API (vscode.lm), enabling
models contributed by any extension that registers a language model chat
provider with VS Code. GitHub Copilot is the most common such vendor, but the
implementation is vendor-agnostic — it selects models via
vscode.lm.selectChatModels and has no Copilot-specific logic.
- VsCodeLmHandler implements the Cline SDK ApiHandler and is registered with the
SDK handler registry; the model selector travels as a vendor/family[/version/id]
string in modelId and is parsed back here. Selector segments are
percent-encoded so values containing slashes round-trip intact.
- Native tool calling: tool definitions are passed to sendRequest and tool calls
are surfaced as tool-call chunks; tool results round-trip as
LanguageModelToolResultPart, with structured tool output serialized to text and
a trailing user message appended when a turn ends on tool results so models can
read the output.
- Gated to VS Code: registration is conditioned on the vscode.lm API being
present, and the provider is hidden in the UI on hosts without it (JetBrains).
Depends on @cline/{shared,llms,agents,core} 0.0.42-nightly.1780514867, the first
published SDK build with the custom-registered-handler routing this provider
needs.
This commit is contained in:
Generated
+1164
-666
File diff suppressed because it is too large
Load Diff
@@ -493,10 +493,10 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
"@cline/agents": "^0.0.42",
|
||||
"@cline/core": "^0.0.42",
|
||||
"@cline/llms": "^0.0.42",
|
||||
"@cline/shared": "^0.0.42",
|
||||
"@cline/agents": "0.0.42-nightly.1780514867",
|
||||
"@cline/core": "0.0.42-nightly.1780514867",
|
||||
"@cline/llms": "0.0.42-nightly.1780514867",
|
||||
"@cline/shared": "0.0.42-nightly.1780514867",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@grpc/grpc-js": "^1.9.15",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
|
||||
import { StateManager } from "./core/storage/StateManager"
|
||||
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
|
||||
import { ErrorService } from "./services/error"
|
||||
import { featureFlagsService } from "./services/feature-flags"
|
||||
import { getDistinctId } from "./services/logging/distinctId"
|
||||
@@ -51,6 +52,11 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
|
||||
})
|
||||
}
|
||||
|
||||
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
|
||||
// which depend on the `vscode` module and cannot live in the SDK package.
|
||||
// Must run before any handler is built (standalone utilities or task loop).
|
||||
registerVsCodeLmHandler()
|
||||
|
||||
// =============== External services ===============
|
||||
await ErrorService.initialize()
|
||||
// Initialize PostHog client provider (skip in self-hosted mode)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, type LanguageDisplay } from
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import type { Settings } from "@shared/storage/state-keys"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { stringifyVsCodeLmModelSelector } from "@shared/vsCodeSelectorUtils"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
@@ -328,6 +329,16 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
|
||||
* Uses mode-specific model ID fields when available, falls back to generic fields.
|
||||
*/
|
||||
export function resolveModelId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
|
||||
// VS Code LM has no plain model-id field: the selected model is stored as a
|
||||
// structured LanguageModelChatSelector ({vendor, family, ...}) in
|
||||
// plan/actModeVsCodeLmModelSelector. The SDK ProviderConfig only carries a
|
||||
// string modelId, so we stringify the selector to "vendor/family[/version/id]"
|
||||
// and the VS Code LM handler parses it back. See sdk/vscode-lm/vscode-lm-handler.ts.
|
||||
if (providerId === "vscode-lm") {
|
||||
const selector = mode === "plan" ? config.planModeVsCodeLmModelSelector : config.actModeVsCodeLmModelSelector
|
||||
return selector ? stringifyVsCodeLmModelSelector(selector) || undefined : undefined
|
||||
}
|
||||
|
||||
// Check provider-specific mode model ID fields.
|
||||
// If the provider has a dedicated field, do not fall back to generic
|
||||
// *ModeApiModelId. Those generic slots may contain a stale model from a
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# VS Code Language Model provider (`vscode-lm`)
|
||||
|
||||
Routes Cline inference through the [VS Code Language Model API](https://code.visualstudio.com/api/extension-guides/language-model)
|
||||
(`vscode.lm`), letting Cline use models contributed by any extension that
|
||||
registers a language model chat provider (a `vendor`) with VS Code. GitHub
|
||||
Copilot is the most common such vendor, but the implementation is
|
||||
vendor-agnostic — it selects models via `vscode.lm.selectChatModels` and has no
|
||||
Copilot-specific logic. VS Code only — the `vscode.lm` API does not exist on
|
||||
other hosts (e.g. JetBrains), so the provider is gated on the API being present.
|
||||
|
||||
## Files
|
||||
|
||||
- **`vscode-lm-handler.ts`** — `VsCodeLmHandler`, a Cline SDK `ApiHandler`
|
||||
(`@cline/llms`) backed by `vscode.lm`. Selects a chat model, streams the
|
||||
response, forwards tool definitions, and surfaces tool calls and usage.
|
||||
- **`vscode-lm-format.ts`** — converts SDK `Message`s to
|
||||
`vscode.LanguageModelChatMessage`s and back-converts tool results.
|
||||
- **`register-vscode-lm.ts`** — registers the handler with the SDK handler
|
||||
registry and exposes the `vscode.lm` availability check used for gating.
|
||||
|
||||
## How it plugs into the SDK
|
||||
|
||||
The SDK's `@cline/llms` handler registry (`registerHandler`) exists for providers
|
||||
that need host-only dependencies — here, `vscode.lm` — which cannot live in the
|
||||
host-agnostic SDK package. `registerVsCodeLmHandler()` (called during extension
|
||||
activation) registers the factory for the `vscode-lm` provider id when the API is
|
||||
available. The SDK then resolves `vscode-lm` to this handler for both the main
|
||||
task loop and standalone utility calls.
|
||||
|
||||
## Model selection
|
||||
|
||||
`ProviderConfig` has no field for a VS Code LM selector, so the selected model
|
||||
travels as a `vendor/family[/version/id]` string in `ProviderConfig.modelId` —
|
||||
the model-id channel the rest of the SDK adapter uses. The webview stores the
|
||||
structured `LanguageModelChatSelector` in
|
||||
`plan/actModeVsCodeLmModelSelector`; the session factory stringifies it into
|
||||
`modelId`, and `parseVsCodeLmSelector` reads it back into a selector here.
|
||||
|
||||
## Tool calling
|
||||
|
||||
Tool definitions are passed to `sendRequest` via `LanguageModelChatRequestOptions.tools`
|
||||
(with `toolMode: Auto`). Tool invocations arrive as `LanguageModelToolCallPart`
|
||||
and are forwarded as tool-call chunks the SDK runtime executes.
|
||||
|
||||
Tool results round-trip as `LanguageModelToolResultPart`. Two details the VS Code
|
||||
LM API requires:
|
||||
|
||||
- **Result content is serialized to text.** SDK tool executors return rich,
|
||||
untyped result objects (e.g. `ToolOperationResult` `{ query, result, success }`),
|
||||
not typed text blocks; `extractToolOutputText` pulls their text out so the model
|
||||
receives the actual output.
|
||||
- **The prompt must not end on a tool result.** Copilot models require the final
|
||||
message to be a non-tool-result user message; when a turn ends on tool results,
|
||||
a short trailing user message is appended.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const registerHandler = vi.fn()
|
||||
vi.mock("@cline/llms", () => ({ registerHandler }))
|
||||
vi.mock("@/shared/services/Logger", () => ({ Logger: { debug: vi.fn() } }))
|
||||
|
||||
// `vscode.lm` presence is the gate. Use a mutable mock so each test controls it.
|
||||
const lm: { selectChatModels?: unknown } = {}
|
||||
vi.mock("vscode", () => ({
|
||||
get lm() {
|
||||
return lm
|
||||
},
|
||||
}))
|
||||
|
||||
describe("registerVsCodeLmHandler gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
registerHandler.mockClear()
|
||||
delete lm.selectChatModels
|
||||
})
|
||||
|
||||
it("does not register when the vscode.lm API is unavailable (e.g. JetBrains)", async () => {
|
||||
const { registerVsCodeLmHandler, isVsCodeLmApiAvailable } = await import("./register-vscode-lm")
|
||||
expect(isVsCodeLmApiAvailable()).toBe(false)
|
||||
registerVsCodeLmHandler()
|
||||
expect(registerHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("registers once when the vscode.lm API is available", async () => {
|
||||
lm.selectChatModels = vi.fn()
|
||||
const { registerVsCodeLmHandler, isVsCodeLmApiAvailable, VSCODE_LM_PROVIDER_ID } = await import("./register-vscode-lm")
|
||||
expect(isVsCodeLmApiAvailable()).toBe(true)
|
||||
registerVsCodeLmHandler()
|
||||
registerVsCodeLmHandler() // idempotent
|
||||
expect(registerHandler).toHaveBeenCalledTimes(1)
|
||||
expect(registerHandler).toHaveBeenCalledWith(VSCODE_LM_PROVIDER_ID, expect.any(Function))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// Registers the VS Code Language Model handler with the Cline SDK.
|
||||
//
|
||||
// The SDK's `@cline/llms` factory registry (registerHandler) exists for
|
||||
// providers that need host-only dependencies — here, the `vscode` module /
|
||||
// `vscode.lm` API — which cannot live in the SDK package. Once registered,
|
||||
// `createHandler({ providerId: "vscode-lm", ... })` returns this handler, and
|
||||
// the agent runtime resolves "vscode-lm" through the same registry, so both the
|
||||
// main task loop and standalone utility calls use the VS Code LM API.
|
||||
//
|
||||
// The `vscode.lm` API only exists in VS Code and derivatives. On other hosts
|
||||
// (e.g. JetBrains, which runs the shared activation path through a `vscode`
|
||||
// shim) it is absent, so registration is gated on the API being present.
|
||||
|
||||
import { registerHandler } from "@cline/llms"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { VsCodeLmHandler } from "./vscode-lm-handler"
|
||||
|
||||
export const VSCODE_LM_PROVIDER_ID = "vscode-lm"
|
||||
|
||||
/**
|
||||
* Whether the host exposes the VS Code Language Model API. False on hosts
|
||||
* without `vscode.lm` (e.g. JetBrains / standalone), where the provider must not
|
||||
* be registered or surfaced.
|
||||
*/
|
||||
export function isVsCodeLmApiAvailable(): boolean {
|
||||
return typeof vscode.lm?.selectChatModels === "function"
|
||||
}
|
||||
|
||||
// Registration is process-global (the SDK registry is a module singleton), so a
|
||||
// module-local flag guards against redundant re-registration on reload.
|
||||
let registered = false
|
||||
|
||||
/**
|
||||
* Register the VS Code LM handler factory, once, when the `vscode.lm` API is
|
||||
* available. No-ops on hosts without the API and on repeated activation.
|
||||
*/
|
||||
export function registerVsCodeLmHandler(): void {
|
||||
if (registered || !isVsCodeLmApiAvailable()) {
|
||||
return
|
||||
}
|
||||
registerHandler(VSCODE_LM_PROVIDER_ID, (config) => new VsCodeLmHandler(config))
|
||||
registered = true
|
||||
Logger.debug("[vscode-lm] Registered VS Code Language Model handler with the SDK")
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Message } from "@cline/llms"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
// The shared vscode vitest stub does not define the Language Model API classes,
|
||||
// so mock the small subset the transform uses. Each class records enough to
|
||||
// assert structure in tests.
|
||||
vi.mock("vscode", () => {
|
||||
class LanguageModelTextPart {
|
||||
constructor(public value: string) {}
|
||||
}
|
||||
class LanguageModelToolCallPart {
|
||||
constructor(
|
||||
public callId: string,
|
||||
public name: string,
|
||||
public input: object,
|
||||
) {}
|
||||
}
|
||||
class LanguageModelToolResultPart {
|
||||
constructor(
|
||||
public callId: string,
|
||||
public content: unknown[],
|
||||
) {}
|
||||
}
|
||||
const LanguageModelChatMessage = {
|
||||
User: (content: unknown) => ({ role: "user", content }),
|
||||
Assistant: (content: unknown) => ({ role: "assistant", content }),
|
||||
}
|
||||
return { LanguageModelTextPart, LanguageModelToolCallPart, LanguageModelToolResultPart, LanguageModelChatMessage }
|
||||
})
|
||||
|
||||
// Logger is referenced by asObjectSafe; stub it to avoid host dependencies.
|
||||
vi.mock("@/shared/services/Logger", () => ({ Logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }))
|
||||
|
||||
const { convertToVsCodeLmMessages, asObjectSafe } = await import("./vscode-lm-format")
|
||||
const vscode = await import("vscode")
|
||||
|
||||
describe("convertToVsCodeLmMessages", () => {
|
||||
it("converts simple string user/assistant messages", () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "hi there" },
|
||||
]
|
||||
const result = convertToVsCodeLmMessages(messages)
|
||||
expect(result).toEqual([
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "hi there" },
|
||||
])
|
||||
})
|
||||
|
||||
it("converts a user message with text + tool_result, tool results first", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "after tool" },
|
||||
{ type: "tool_result", tool_use_id: "call-1", name: "read", content: "file contents" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [message] = convertToVsCodeLmMessages(messages)
|
||||
const content = message.content as unknown[]
|
||||
// Tool result part is emitted before the text part.
|
||||
expect(content[0]).toBeInstanceOf(vscode.LanguageModelToolResultPart)
|
||||
expect((content[0] as { callId: string }).callId).toBe("call-1")
|
||||
expect(content[1]).toBeInstanceOf(vscode.LanguageModelTextPart)
|
||||
expect((content[1] as { value: string }).value).toBe("after tool")
|
||||
})
|
||||
|
||||
it("renders images as placeholders (VS Code LM does not accept images)", () => {
|
||||
const messages: Message[] = [{ role: "user", content: [{ type: "image", data: "abc", mediaType: "image/png" }] }]
|
||||
const [message] = convertToVsCodeLmMessages(messages)
|
||||
const part = (message.content as { value: string }[])[0]
|
||||
expect(part).toBeInstanceOf(vscode.LanguageModelTextPart)
|
||||
expect(part.value).toContain("image/png")
|
||||
expect(part.value).toContain("not supported")
|
||||
})
|
||||
|
||||
it("extracts text from structured (untyped) tool_result content (run_commands shape)", () => {
|
||||
// SDK tool executors return rich, untyped objects (ToolOperationResult:
|
||||
// { query, result, success }) — NOT typed text blocks. The result text
|
||||
// must reach the model, not an empty string.
|
||||
const messages: Message[] = [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "c1", name: "run_commands", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "c1",
|
||||
name: "run_commands",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: simulating runtime untyped content
|
||||
content: [{ query: "ls", result: "demo.ts\ndemo.txt\nsrc\n", success: true }] as any,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "continue" },
|
||||
]
|
||||
const result = convertToVsCodeLmMessages(messages)
|
||||
const toolResultMsg = result[1]
|
||||
const trPart = (toolResultMsg.content as { content: { value: string }[] }[])[0]
|
||||
expect(trPart).toBeInstanceOf(vscode.LanguageModelToolResultPart)
|
||||
// The inner text part must contain the actual command output, not "".
|
||||
expect(trPart.content[0].value).toBe("demo.ts\ndemo.txt\nsrc\n")
|
||||
})
|
||||
|
||||
it("appends a trailing plain user message when the convo ends on tool results", () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "c1", name: "read", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "c1", name: "read", content: "file body" }] },
|
||||
]
|
||||
const result = convertToVsCodeLmMessages(messages)
|
||||
// Last message must NOT be the tool-result one (Copilot requirement); it's
|
||||
// a plain string user message (LanguageModelChatMessage.User(string)).
|
||||
const last = result[result.length - 1]
|
||||
expect(last.role).toBe("user")
|
||||
expect(typeof last.content).toBe("string")
|
||||
expect(last.content as unknown as string).toContain("result of calling one or more tools")
|
||||
// The tool-result message is preserved before the nudge.
|
||||
expect(result).toHaveLength(3)
|
||||
expect((result[1].content as unknown[])[0]).toBeInstanceOf(vscode.LanguageModelToolResultPart)
|
||||
})
|
||||
|
||||
it("does NOT append a trailing nudge when the convo ends on a normal user message", () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "c1", name: "read", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "c1", name: "read", content: "body" }] },
|
||||
{ role: "user", content: "thanks, continue" },
|
||||
]
|
||||
const result = convertToVsCodeLmMessages(messages)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[result.length - 1].content).toBe("thanks, continue")
|
||||
})
|
||||
|
||||
it("converts an assistant tool_use into a tool call part with object input", () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call-9", name: "edit", input: { path: "a.ts" } }] },
|
||||
]
|
||||
const [message] = convertToVsCodeLmMessages(messages)
|
||||
const part = (message.content as unknown[])[0]
|
||||
expect(part).toBeInstanceOf(vscode.LanguageModelToolCallPart)
|
||||
expect((part as { name: string }).name).toBe("edit")
|
||||
expect((part as { input: object }).input).toEqual({ path: "a.ts" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("asObjectSafe", () => {
|
||||
it("parses JSON strings", () => {
|
||||
expect(asObjectSafe('{"a":1}')).toEqual({ a: 1 })
|
||||
})
|
||||
it("clones objects", () => {
|
||||
const input = { a: 1 }
|
||||
expect(asObjectSafe(input)).toEqual({ a: 1 })
|
||||
expect(asObjectSafe(input)).not.toBe(input)
|
||||
})
|
||||
it("returns {} for nullish or invalid", () => {
|
||||
expect(asObjectSafe(null)).toEqual({})
|
||||
expect(asObjectSafe("not json")).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
// Converts Cline SDK messages (@cline/llms `Message`) into VS Code Language
|
||||
// Model API messages (`vscode.LanguageModelChatMessage`).
|
||||
//
|
||||
// SDK content blocks (text / image / tool_use / tool_result) map onto VS Code LM
|
||||
// parts. VS Code LM does not accept images, so image inputs become a text
|
||||
// placeholder. Tool calls and tool results round-trip as
|
||||
// `LanguageModelToolCallPart` / `LanguageModelToolResultPart`.
|
||||
|
||||
import type { ContentBlock, Message } from "@cline/llms";
|
||||
import * as vscode from "vscode";
|
||||
import { Logger } from "@/shared/services/Logger";
|
||||
import { extractToolOutputText } from "../message-translator";
|
||||
|
||||
/**
|
||||
* Safely coerce a tool-call input value into a plain object for
|
||||
* `LanguageModelToolCallPart`, which requires an object input.
|
||||
*/
|
||||
export function asObjectSafe(value: unknown): object {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof value === "string") {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.assign({}, value);
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
Logger.warn("Cline <Language Model API>: Failed to parse object:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function imagePlaceholder(mediaType?: string): vscode.LanguageModelTextPart {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${mediaType || "unknown media-type"}) not supported by VSCode LM API]`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the content of a tool_result block into VS Code LM text parts.
|
||||
*/
|
||||
function toolResultTextParts(
|
||||
content: ContentBlock & { type: "tool_result" },
|
||||
): vscode.LanguageModelTextPart[] {
|
||||
if (typeof content.content === "string") {
|
||||
return [new vscode.LanguageModelTextPart(content.content)];
|
||||
}
|
||||
|
||||
// SDK tool executors (run_commands, search_codebase, read_files, MCP, ...)
|
||||
// put rich, untyped result objects in `content` — e.g. an array of
|
||||
// ToolOperationResult `{ query, result, success, error? }` — rather than typed
|
||||
// text/image/file content blocks. Such items have no `.type`/`.text`, so they
|
||||
// can only be read via extractToolOutputText (which pulls `.result`/`.error`
|
||||
// out of ToolOperationResult[], else falls back to JSON.stringify). When no
|
||||
// item looks like a typed content block, treat the whole array as untyped
|
||||
// output and extract its text in one pass.
|
||||
const isTypedBlock = (
|
||||
part: unknown,
|
||||
): part is { type: "text" | "image" | "file" } =>
|
||||
typeof part === "object" &&
|
||||
part !== null &&
|
||||
typeof (part as { type?: unknown }).type === "string";
|
||||
|
||||
if (!content.content.some(isTypedBlock)) {
|
||||
const text = extractToolOutputText(content.content);
|
||||
return [new vscode.LanguageModelTextPart(text)];
|
||||
}
|
||||
|
||||
const parts = content.content.map((part) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return new vscode.LanguageModelTextPart(part.text ?? "");
|
||||
case "image":
|
||||
return imagePlaceholder(part.mediaType);
|
||||
case "file":
|
||||
return new vscode.LanguageModelTextPart(part.content ?? "");
|
||||
default:
|
||||
// Untyped item interleaved with typed blocks — serialize just this item.
|
||||
return new vscode.LanguageModelTextPart(extractToolOutputText(part));
|
||||
}
|
||||
});
|
||||
|
||||
return parts.length > 0 ? parts : [new vscode.LanguageModelTextPart("")];
|
||||
}
|
||||
|
||||
export function convertToVsCodeLmMessages(
|
||||
messages: Message[],
|
||||
): vscode.LanguageModelChatMessage[] {
|
||||
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
// Simple string content.
|
||||
if (typeof message.content === "string") {
|
||||
vsCodeLmMessages.push(
|
||||
message.role === "assistant"
|
||||
? vscode.LanguageModelChatMessage.Assistant(message.content)
|
||||
: vscode.LanguageModelChatMessage.User(message.content),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
const toolResults = message.content.filter(
|
||||
(part): part is ContentBlock & { type: "tool_result" } =>
|
||||
part.type === "tool_result",
|
||||
);
|
||||
const nonTool = message.content.filter(
|
||||
(part) => part.type === "text" || part.type === "image",
|
||||
);
|
||||
|
||||
const contentParts = [
|
||||
// Tool results first, as VS Code LM tool result parts.
|
||||
...toolResults.map(
|
||||
(toolResult) =>
|
||||
new vscode.LanguageModelToolResultPart(
|
||||
toolResult.tool_use_id,
|
||||
toolResultTextParts(toolResult),
|
||||
),
|
||||
),
|
||||
// Then text / image-placeholder parts.
|
||||
...nonTool.map((part) =>
|
||||
part.type === "image"
|
||||
? imagePlaceholder(part.mediaType)
|
||||
: new vscode.LanguageModelTextPart(
|
||||
part.type === "text" ? part.text : "",
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const toolUses = message.content.filter(
|
||||
(part): part is ContentBlock & { type: "tool_use" } =>
|
||||
part.type === "tool_use",
|
||||
);
|
||||
const nonTool = message.content.filter(
|
||||
(part) => part.type === "text" || part.type === "image",
|
||||
);
|
||||
|
||||
const contentParts = [
|
||||
// Tool calls first.
|
||||
...toolUses.map(
|
||||
(toolUse) =>
|
||||
new vscode.LanguageModelToolCallPart(
|
||||
toolUse.id,
|
||||
toolUse.name,
|
||||
asObjectSafe(toolUse.input),
|
||||
),
|
||||
),
|
||||
// Then text (images are not generated/produced for assistant turns).
|
||||
...nonTool.map((part) =>
|
||||
part.type === "image"
|
||||
? new vscode.LanguageModelTextPart(
|
||||
"[Image generation not supported by VSCode LM API]",
|
||||
)
|
||||
: new vscode.LanguageModelTextPart(
|
||||
part.type === "text" ? part.text : "",
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
vsCodeLmMessages.push(
|
||||
vscode.LanguageModelChatMessage.Assistant(contentParts),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Copilot (and other VS Code LM) models require that the prompt does NOT end
|
||||
// on a tool-result message — the final message must be a non-tool-result
|
||||
// UserMessage. If we end on tool results, the model can't "see" the output and
|
||||
// tends to re-run tools or call random ones. Mirror the official VS Code chat
|
||||
// sample (chat-sample/src/toolsPrompt.tsx), which appends a trailing user
|
||||
// nudge after tool results.
|
||||
if (endsWithToolResult(vsCodeLmMessages)) {
|
||||
vsCodeLmMessages.push(
|
||||
vscode.LanguageModelChatMessage.User(
|
||||
"Above is the result of calling one or more tools. The user cannot see the results, so you should explain them to the user if referencing them in your answer.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return vsCodeLmMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the last message is a user message whose content consists of (only)
|
||||
* tool result parts.
|
||||
*/
|
||||
function endsWithToolResult(
|
||||
messages: vscode.LanguageModelChatMessage[],
|
||||
): boolean {
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || !Array.isArray(last.content) || last.content.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return last.content.some(
|
||||
(part) => part instanceof vscode.LanguageModelToolResultPart,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { ProviderConfig, ToolDefinition } from "@cline/llms";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Real-enough `vscode` mock: the handler uses `instanceof` on the stream parts
|
||||
// and the LanguageModelChatToolMode enum, and constructs a CancellationTokenSource.
|
||||
vi.mock("vscode", () => {
|
||||
class LanguageModelTextPart {
|
||||
constructor(public value: string) {}
|
||||
}
|
||||
class LanguageModelToolCallPart {
|
||||
constructor(
|
||||
public callId: string,
|
||||
public name: string,
|
||||
public input: object,
|
||||
) {}
|
||||
}
|
||||
class LanguageModelToolResultPart {
|
||||
constructor(
|
||||
public callId: string,
|
||||
public content: unknown[],
|
||||
) {}
|
||||
}
|
||||
class CancellationTokenSource {
|
||||
token = {};
|
||||
cancel = vi.fn();
|
||||
dispose = vi.fn();
|
||||
}
|
||||
class CancellationError extends Error {}
|
||||
return {
|
||||
LanguageModelTextPart,
|
||||
LanguageModelToolCallPart,
|
||||
LanguageModelToolResultPart,
|
||||
CancellationTokenSource,
|
||||
CancellationError,
|
||||
LanguageModelChatToolMode: { Auto: 1, Required: 2 },
|
||||
lm: { selectChatModels: vi.fn() },
|
||||
LanguageModelChatMessage: {
|
||||
User: (content: unknown) => ({ role: "user", content }),
|
||||
Assistant: (content: unknown) => ({ role: "assistant", content }),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
Logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const { parseVsCodeLmSelector, VsCodeLmHandler } = await import(
|
||||
"./vscode-lm-handler"
|
||||
);
|
||||
const vscode = await import("vscode");
|
||||
|
||||
describe("parseVsCodeLmSelector", () => {
|
||||
it("returns an empty selector for empty/undefined input", () => {
|
||||
expect(parseVsCodeLmSelector(undefined)).toEqual({});
|
||||
expect(parseVsCodeLmSelector("")).toEqual({});
|
||||
});
|
||||
|
||||
it("parses vendor/family (the common case)", () => {
|
||||
expect(parseVsCodeLmSelector("copilot/claude-sonnet")).toEqual({
|
||||
vendor: "copilot",
|
||||
family: "claude-sonnet",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses vendor/family/version/id positionally", () => {
|
||||
expect(parseVsCodeLmSelector("copilot/claude-sonnet/1.0/abc")).toEqual({
|
||||
vendor: "copilot",
|
||||
family: "claude-sonnet",
|
||||
version: "1.0",
|
||||
id: "abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses encoded family values containing slashes", () => {
|
||||
expect(parseVsCodeLmSelector("copilot/openai%2Fgpt-4o")).toEqual({
|
||||
vendor: "copilot",
|
||||
family: "openai/gpt-4o",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits trailing missing segments", () => {
|
||||
expect(parseVsCodeLmSelector("copilot")).toEqual({ vendor: "copilot" });
|
||||
});
|
||||
});
|
||||
|
||||
// Build a fake LanguageModelChat whose stream yields the given parts, and
|
||||
// capture the requestOptions passed to sendRequest for assertions.
|
||||
function fakeModel(parts: unknown[]) {
|
||||
const calls: { options: Record<string, unknown> }[] = [];
|
||||
const model = {
|
||||
id: "copilot-claude",
|
||||
name: "Claude",
|
||||
vendor: "copilot",
|
||||
family: "claude-sonnet",
|
||||
version: "1",
|
||||
maxInputTokens: 200_000,
|
||||
sendRequest: vi.fn(
|
||||
(_messages: unknown, options: Record<string, unknown>) => {
|
||||
calls.push({ options });
|
||||
return Promise.resolve({
|
||||
stream: (async function* () {
|
||||
for (const part of parts) {
|
||||
yield part;
|
||||
}
|
||||
})(),
|
||||
text: (async function* () {})(),
|
||||
});
|
||||
},
|
||||
),
|
||||
countTokens: vi.fn(async () => 0),
|
||||
};
|
||||
return { model, calls };
|
||||
}
|
||||
|
||||
async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
|
||||
const out: T[] = [];
|
||||
for await (const v of gen) {
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const config = {
|
||||
providerId: "vscode-lm",
|
||||
modelId: "copilot/claude-sonnet",
|
||||
} as ProviderConfig;
|
||||
|
||||
describe("VsCodeLmHandler.createMessage tool calling", () => {
|
||||
it("passes tools + Auto toolMode to sendRequest when tools are provided", async () => {
|
||||
const { model, calls } = fakeModel([
|
||||
new vscode.LanguageModelTextPart("hello"),
|
||||
]);
|
||||
vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([model] as never);
|
||||
|
||||
const handler = new VsCodeLmHandler(config);
|
||||
const tools: ToolDefinition[] = [
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Reads a file",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
},
|
||||
},
|
||||
];
|
||||
await collect(
|
||||
handler.createMessage("sys", [{ role: "user", content: "hi" }], tools),
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options.toolMode).toBe(
|
||||
vscode.LanguageModelChatToolMode.Auto,
|
||||
);
|
||||
expect(calls[0].options.tools).toEqual([
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Reads a file",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not set tools when none are provided", async () => {
|
||||
const { model, calls } = fakeModel([
|
||||
new vscode.LanguageModelTextPart("hi"),
|
||||
]);
|
||||
vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([model] as never);
|
||||
|
||||
const handler = new VsCodeLmHandler(config);
|
||||
await collect(
|
||||
handler.createMessage("sys", [{ role: "user", content: "hi" }]),
|
||||
);
|
||||
|
||||
expect(calls[0].options.tools).toBeUndefined();
|
||||
expect(calls[0].options.toolMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits a native tool_calls chunk for a LanguageModelToolCallPart", async () => {
|
||||
const { model } = fakeModel([
|
||||
new vscode.LanguageModelTextPart("thinking"),
|
||||
new vscode.LanguageModelToolCallPart("call-1", "read_file", {
|
||||
path: "a.ts",
|
||||
}),
|
||||
]);
|
||||
vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([model] as never);
|
||||
|
||||
const handler = new VsCodeLmHandler(config);
|
||||
const chunks = await collect(
|
||||
handler.createMessage(
|
||||
"sys",
|
||||
[{ role: "user", content: "hi" }],
|
||||
[{ name: "read_file", description: "", inputSchema: {} }],
|
||||
),
|
||||
);
|
||||
|
||||
const toolChunk = chunks.find((c) => c.type === "tool_calls");
|
||||
expect(toolChunk).toEqual({
|
||||
type: "tool_calls",
|
||||
id: expect.any(String),
|
||||
tool_call: {
|
||||
call_id: "call-1",
|
||||
function: {
|
||||
id: "call-1",
|
||||
name: "read_file",
|
||||
arguments: { path: "a.ts" },
|
||||
},
|
||||
},
|
||||
});
|
||||
// A text chunk and a final usage chunk are also present.
|
||||
expect(chunks.some((c) => c.type === "text" && c.text === "thinking")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(chunks.at(-1)?.type).toBe("usage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
// Cline SDK `ApiHandler` (from `@cline/llms`) backed by the VS Code Language
|
||||
// Model API (`vscode.lm`). Registered via `registerHandler("vscode-lm", ...)`
|
||||
// (see ./register-vscode-lm.ts) so the SDK routes "vscode-lm" inference here.
|
||||
//
|
||||
// Model selection: the SDK `ProviderConfig` has no field for a VS Code LM
|
||||
// selector, so the selector travels as a `vendor/family[/version/id]` string in
|
||||
// `ProviderConfig.modelId` (the model-id channel the rest of the SDK adapter
|
||||
// uses) and is parsed back into a `LanguageModelChatSelector` here. See
|
||||
// parseVsCodeLmSelector / apps/vscode/src/shared/vsCodeSelectorUtils.ts.
|
||||
|
||||
import type {
|
||||
ApiHandler,
|
||||
ApiStreamChunk,
|
||||
HandlerModelInfo,
|
||||
Message,
|
||||
ProviderConfig,
|
||||
ToolDefinition,
|
||||
} from "@cline/llms";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as vscode from "vscode";
|
||||
import { Logger } from "@/shared/services/Logger";
|
||||
import {
|
||||
parseVsCodeLmModelSelector,
|
||||
SELECTOR_SEPARATOR,
|
||||
} from "@/shared/vsCodeSelectorUtils";
|
||||
import { convertToVsCodeLmMessages } from "./vscode-lm-format";
|
||||
|
||||
const FALLBACK_CONTEXT_WINDOW = 128_000;
|
||||
|
||||
/**
|
||||
* Parse a stringified VS Code LM selector (`vendor/family[/version/id]`) back
|
||||
* into a `LanguageModelChatSelector`. The model picker commonly supplies just
|
||||
* `vendor` and `family`; any further segments map to version/id positionally,
|
||||
* mirroring `stringifyVsCodeLmModelSelector`.
|
||||
*/
|
||||
export function parseVsCodeLmSelector(
|
||||
modelId: string | undefined,
|
||||
): vscode.LanguageModelChatSelector {
|
||||
return parseVsCodeLmModelSelector(modelId);
|
||||
}
|
||||
|
||||
function extractText(message: vscode.LanguageModelChatMessage): string {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.filter(
|
||||
(part): part is vscode.LanguageModelTextPart =>
|
||||
part instanceof vscode.LanguageModelTextPart,
|
||||
)
|
||||
.map((part) => part.value)
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export class VsCodeLmHandler implements ApiHandler {
|
||||
private readonly selector: vscode.LanguageModelChatSelector;
|
||||
private client: vscode.LanguageModelChat | null = null;
|
||||
private currentRequestCancellation: vscode.CancellationTokenSource | null =
|
||||
null;
|
||||
private abortSignal: AbortSignal | undefined;
|
||||
|
||||
constructor(config: ProviderConfig) {
|
||||
this.selector = parseVsCodeLmSelector(config.modelId);
|
||||
this.abortSignal = config.abortSignal;
|
||||
}
|
||||
|
||||
setAbortSignal(signal: AbortSignal | undefined): void {
|
||||
this.abortSignal = signal;
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.ensureCleanState();
|
||||
}
|
||||
|
||||
private ensureCleanState(): void {
|
||||
if (this.currentRequestCancellation) {
|
||||
this.currentRequestCancellation.cancel();
|
||||
this.currentRequestCancellation.dispose();
|
||||
this.currentRequestCancellation = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getClient(): Promise<vscode.LanguageModelChat> {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
try {
|
||||
const models = await vscode.lm.selectChatModels(this.selector);
|
||||
if (models && models.length > 0) {
|
||||
this.client = models[0];
|
||||
return this.client;
|
||||
}
|
||||
throw new Error(
|
||||
`No VS Code language model matched selector ${JSON.stringify(this.selector)}. Ensure GitHub Copilot (or another LM provider) is installed and the model is enabled.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
throw new Error(
|
||||
`Cline <Language Model API>: Failed to select model: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getMessages(
|
||||
systemPrompt: string,
|
||||
messages: Message[],
|
||||
): vscode.LanguageModelChatMessage[] {
|
||||
return [
|
||||
vscode.LanguageModelChatMessage.Assistant(systemPrompt),
|
||||
...convertToVsCodeLmMessages(messages),
|
||||
];
|
||||
}
|
||||
|
||||
// VS Code LM does not expose a tokenizer cheaply, so approximate as chars/4.
|
||||
// Good enough for budgeting and usage display.
|
||||
private approxTokens(text: string): number {
|
||||
return Math.ceil((text || "").length / 4);
|
||||
}
|
||||
|
||||
async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Message[],
|
||||
tools?: ToolDefinition[],
|
||||
): AsyncGenerator<ApiStreamChunk> {
|
||||
this.ensureCleanState();
|
||||
const client = await this.getClient();
|
||||
const id = nanoid();
|
||||
|
||||
const vsCodeLmMessages = this.getMessages(systemPrompt, messages);
|
||||
const inputTokens = vsCodeLmMessages.reduce(
|
||||
(sum, msg) => sum + this.approxTokens(extractText(msg)),
|
||||
0,
|
||||
);
|
||||
|
||||
this.currentRequestCancellation = new vscode.CancellationTokenSource();
|
||||
if (this.abortSignal) {
|
||||
if (this.abortSignal.aborted) {
|
||||
this.currentRequestCancellation.cancel();
|
||||
} else {
|
||||
this.abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => this.currentRequestCancellation?.cancel(),
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let accumulatedText = "";
|
||||
|
||||
try {
|
||||
const requestOptions: vscode.LanguageModelChatRequestOptions = {
|
||||
justification: `Cline would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`,
|
||||
};
|
||||
|
||||
// Native tool calling: the VS Code LM API (finalized in VS Code 1.95)
|
||||
// accepts tool definitions on the request and streams back
|
||||
// LanguageModelToolCallPart for invocations. Map the SDK's
|
||||
// ToolDefinition[] onto vscode.LanguageModelChatTool[]; tool RESULTS are
|
||||
// already round-tripped as LanguageModelToolResultPart by
|
||||
// convertToVsCodeLmMessages. We use "Auto" tool mode so the model
|
||||
// decides when to call tools (vs. forcing a call).
|
||||
if (tools && tools.length > 0) {
|
||||
requestOptions.tools = tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}));
|
||||
requestOptions.toolMode = vscode.LanguageModelChatToolMode.Auto;
|
||||
}
|
||||
|
||||
const response = await client.sendRequest(
|
||||
vsCodeLmMessages,
|
||||
requestOptions,
|
||||
this.currentRequestCancellation.token,
|
||||
);
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
if (chunk instanceof vscode.LanguageModelTextPart) {
|
||||
if (typeof chunk.value !== "string") {
|
||||
continue;
|
||||
}
|
||||
accumulatedText += chunk.value;
|
||||
const textChunk: ApiStreamChunk = {
|
||||
type: "text",
|
||||
text: chunk.value,
|
||||
id,
|
||||
};
|
||||
yield textChunk;
|
||||
} else if (chunk instanceof vscode.LanguageModelToolCallPart) {
|
||||
// A tool invocation from the model. callId/name/input come straight
|
||||
// from the API (input is already a parsed object); forward them as a
|
||||
// tool-call chunk for the SDK runtime to execute.
|
||||
if (!chunk.name || !chunk.callId || !chunk.input) {
|
||||
continue;
|
||||
}
|
||||
const toolCallChunk: ApiStreamChunk = {
|
||||
type: "tool_calls",
|
||||
id,
|
||||
tool_call: {
|
||||
call_id: chunk.callId,
|
||||
function: {
|
||||
id: chunk.callId,
|
||||
name: chunk.name,
|
||||
arguments: chunk.input as Record<string, unknown>,
|
||||
},
|
||||
},
|
||||
};
|
||||
// Approximate output token accounting still wants the tool call's
|
||||
// size; serialize for the estimate without affecting the chunk.
|
||||
accumulatedText += JSON.stringify({
|
||||
name: chunk.name,
|
||||
input: chunk.input,
|
||||
callId: chunk.callId,
|
||||
});
|
||||
yield toolCallChunk;
|
||||
}
|
||||
}
|
||||
|
||||
const usageChunk: ApiStreamChunk = {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens: this.approxTokens(accumulatedText),
|
||||
totalCost: 0,
|
||||
id,
|
||||
};
|
||||
yield usageChunk;
|
||||
} catch (error) {
|
||||
this.ensureCleanState();
|
||||
if (error instanceof vscode.CancellationError) {
|
||||
throw new Error(
|
||||
"Cline <Language Model API>: Request cancelled by user",
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
Logger.error("Cline <Language Model API>: Stream error:", error);
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`Cline <Language Model API>: Response stream error: ${String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.ensureCleanState();
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): HandlerModelInfo {
|
||||
if (this.client) {
|
||||
const modelParts = [
|
||||
this.client.vendor,
|
||||
this.client.family,
|
||||
this.client.version,
|
||||
].filter(Boolean);
|
||||
const modelId = this.client.id || modelParts.join(SELECTOR_SEPARATOR);
|
||||
const contextWindow =
|
||||
typeof this.client.maxInputTokens === "number" &&
|
||||
this.client.maxInputTokens > 0
|
||||
? this.client.maxInputTokens
|
||||
: FALLBACK_CONTEXT_WINDOW;
|
||||
return {
|
||||
id: modelId,
|
||||
info: {
|
||||
id: modelId,
|
||||
contextWindow,
|
||||
maxInputTokens: contextWindow,
|
||||
maxTokens: -1,
|
||||
description: `VSCode Language Model: ${modelId}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// No client yet (e.g. getModel() called before createMessage()).
|
||||
const fallbackId =
|
||||
[
|
||||
this.selector.vendor,
|
||||
this.selector.family,
|
||||
this.selector.version,
|
||||
this.selector.id,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(SELECTOR_SEPARATOR) || "vscode-lm";
|
||||
return {
|
||||
id: fallbackId,
|
||||
info: {
|
||||
id: fallbackId,
|
||||
contextWindow: FALLBACK_CONTEXT_WINDOW,
|
||||
maxInputTokens: FALLBACK_CONTEXT_WINDOW,
|
||||
maxTokens: -1,
|
||||
description: `VSCode Language Model (Fallback): ${fallbackId}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseVsCodeLmModelSelector,
|
||||
stringifyVsCodeLmModelSelector,
|
||||
} from "./vsCodeSelectorUtils";
|
||||
|
||||
describe("VS Code LM selector string encoding", () => {
|
||||
it("round-trips vendor/family selectors", () => {
|
||||
const selector = { vendor: "copilot", family: "claude-sonnet" };
|
||||
const value = stringifyVsCodeLmModelSelector(selector);
|
||||
|
||||
expect(value).toBe("copilot/claude-sonnet");
|
||||
expect(parseVsCodeLmModelSelector(value)).toEqual(selector);
|
||||
});
|
||||
|
||||
it("preserves family values containing slashes", () => {
|
||||
const selector = { vendor: "copilot", family: "openai/gpt-4o" };
|
||||
const value = stringifyVsCodeLmModelSelector(selector);
|
||||
|
||||
expect(value).toBe("copilot/openai%2Fgpt-4o");
|
||||
expect(parseVsCodeLmModelSelector(value)).toEqual(selector);
|
||||
});
|
||||
|
||||
it("round-trips optional version and id segments", () => {
|
||||
const selector = {
|
||||
vendor: "copilot",
|
||||
family: "anthropic/claude",
|
||||
version: "2026/06",
|
||||
id: "model/id",
|
||||
};
|
||||
const value = stringifyVsCodeLmModelSelector(selector);
|
||||
|
||||
expect(parseVsCodeLmModelSelector(value)).toEqual(selector);
|
||||
});
|
||||
|
||||
it("keeps compatibility with old unencoded vendor/family values", () => {
|
||||
expect(parseVsCodeLmModelSelector("copilot/claude-sonnet")).toEqual({
|
||||
vendor: "copilot",
|
||||
family: "claude-sonnet",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,61 @@
|
||||
import { LanguageModelChatSelector } from "vscode"
|
||||
import type { LanguageModelChatSelector } from "vscode";
|
||||
|
||||
export const SELECTOR_SEPARATOR = "/"
|
||||
export const SELECTOR_SEPARATOR = "/";
|
||||
|
||||
export function stringifyVsCodeLmModelSelector(selector: LanguageModelChatSelector): string {
|
||||
return [selector.vendor, selector.family, selector.version, selector.id].filter(Boolean).join(SELECTOR_SEPARATOR)
|
||||
function encodeSelectorSegment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function decodeSelectorSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
// A segment that isn't valid percent-encoding is passed through as-is, so
|
||||
// a malformed or hand-written selector string still yields a usable value.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyVsCodeLmModelSelector(
|
||||
selector: LanguageModelChatSelector,
|
||||
): string {
|
||||
return [selector.vendor, selector.family, selector.version, selector.id]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.map(encodeSelectorSegment)
|
||||
.join(SELECTOR_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stringified VS Code LM selector (`vendor/family[/version/id]`) back
|
||||
* into a `LanguageModelChatSelector`.
|
||||
*
|
||||
* Selector segments are URI-encoded by stringifyVsCodeLmModelSelector so model
|
||||
* family/id values can themselves contain `/` (common for BYOK providers such as
|
||||
* `openai/gpt-4o`). Splitting on the separator therefore yields up to four
|
||||
* positional segments (vendor, family, version, id); each is decoded
|
||||
* independently, and missing trailing segments are simply omitted.
|
||||
*/
|
||||
export function parseVsCodeLmModelSelector(
|
||||
modelId: string | undefined,
|
||||
): LanguageModelChatSelector {
|
||||
if (!modelId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parts = modelId.split(SELECTOR_SEPARATOR);
|
||||
const [vendor, family, version, id] = parts.map(decodeSelectorSegment);
|
||||
const selector: LanguageModelChatSelector = {};
|
||||
if (vendor) {
|
||||
selector.vendor = vendor;
|
||||
}
|
||||
if (family) {
|
||||
selector.family = family;
|
||||
}
|
||||
if (version) {
|
||||
selector.version = version;
|
||||
}
|
||||
if (id) {
|
||||
selector.id = id;
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vitest/config"
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Vitest config for the VSCode extension's SDK-adapter and model-catalog
|
||||
// unit tests. (The bulk of the extension's unit tests still run under mocha
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
test: {
|
||||
include: [
|
||||
"src/sdk/**/*.test.ts",
|
||||
"src/shared/vsCodeSelectorUtils.test.ts",
|
||||
"src/core/storage/remote-config/**/*.test.ts",
|
||||
"src/shared/model-catalog/provider-helpers.test.ts",
|
||||
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
|
||||
@@ -24,9 +25,18 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@cline/core": path.resolve(__dirname, "src/test/cline-core-vitest-stub.ts"),
|
||||
"@cline/llms": path.resolve(__dirname, "node_modules/@cline/llms/dist/index.js"),
|
||||
"@cline/shared": path.resolve(__dirname, "node_modules/@cline/shared/dist/index.js"),
|
||||
"@cline/core": path.resolve(
|
||||
__dirname,
|
||||
"src/test/cline-core-vitest-stub.ts",
|
||||
),
|
||||
"@cline/llms": path.resolve(
|
||||
__dirname,
|
||||
"node_modules/@cline/llms/dist/index.js",
|
||||
),
|
||||
"@cline/shared": path.resolve(
|
||||
__dirname,
|
||||
"node_modules/@cline/shared/dist/index.js",
|
||||
),
|
||||
vscode: path.resolve(__dirname, "src/test/vscode-vitest-stub.ts"),
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@api": path.resolve(__dirname, "src/core/api"),
|
||||
@@ -35,12 +45,18 @@ export default defineConfig({
|
||||
"@hosts": path.resolve(__dirname, "src/hosts"),
|
||||
"@integrations": path.resolve(__dirname, "src/integrations"),
|
||||
"@services": path.resolve(__dirname, "src/services"),
|
||||
"@shared/proto/cline/common": path.resolve(__dirname, "src/shared/proto/cline/common.ts"),
|
||||
"@shared/proto/cline/models": path.resolve(__dirname, "src/shared/proto/cline/models.ts"),
|
||||
"@shared/proto/cline/common": path.resolve(
|
||||
__dirname,
|
||||
"src/shared/proto/cline/common.ts",
|
||||
),
|
||||
"@shared/proto/cline/models": path.resolve(
|
||||
__dirname,
|
||||
"src/shared/proto/cline/models.ts",
|
||||
),
|
||||
"@shared/proto": path.resolve(__dirname, "src/shared/proto"),
|
||||
"@shared": path.resolve(__dirname, "src/shared"),
|
||||
"@utils": path.resolve(__dirname, "src/utils"),
|
||||
"@packages": path.resolve(__dirname, "src/packages"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useInterval } from "react-use"
|
||||
import * as vscodemodels from "vscode"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { ModelsServiceClient } from "@/services/grpc-client"
|
||||
import { DROPDOWN_Z_INDEX, DropdownContainer } from "../ApiOptions"
|
||||
import { getModeSpecificFields } from "../utils/providerUtils"
|
||||
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common";
|
||||
import type { Mode } from "@shared/storage/types";
|
||||
import {
|
||||
parseVsCodeLmModelSelector,
|
||||
stringifyVsCodeLmModelSelector,
|
||||
} from "@shared/vsCodeSelectorUtils";
|
||||
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useInterval } from "react-use";
|
||||
import type * as vscodemodels from "vscode";
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext";
|
||||
import { ModelsServiceClient } from "@/services/grpc-client";
|
||||
import { DROPDOWN_Z_INDEX, DropdownContainer } from "../ApiOptions";
|
||||
import { getModeSpecificFields } from "../utils/providerUtils";
|
||||
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers";
|
||||
|
||||
interface VSCodeLmProviderProps {
|
||||
currentMode: Mode
|
||||
currentMode: Mode;
|
||||
}
|
||||
|
||||
export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
|
||||
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
|
||||
const { apiConfiguration } = useExtensionState()
|
||||
const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers()
|
||||
const [vsCodeLmModels, setVsCodeLmModels] = useState<
|
||||
vscodemodels.LanguageModelChatSelector[]
|
||||
>([]);
|
||||
const { apiConfiguration } = useExtensionState();
|
||||
const { handleModeFieldChange } = useApiConfigurationHandlers();
|
||||
|
||||
const { vsCodeLmModelSelector } = getModeSpecificFields(apiConfiguration, currentMode)
|
||||
const { vsCodeLmModelSelector } = getModeSpecificFields(
|
||||
apiConfiguration,
|
||||
currentMode,
|
||||
);
|
||||
|
||||
// Poll VS Code LM models
|
||||
const requestVsCodeLmModels = useCallback(async () => {
|
||||
try {
|
||||
const response = await ModelsServiceClient.getVsCodeLmModels(EmptyRequest.create({}))
|
||||
if (response && response.models) {
|
||||
setVsCodeLmModels(response.models)
|
||||
const response = await ModelsServiceClient.getVsCodeLmModels(
|
||||
EmptyRequest.create({}),
|
||||
);
|
||||
if (response?.models) {
|
||||
setVsCodeLmModels(response.models);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch VS Code LM models:", error)
|
||||
setVsCodeLmModels([])
|
||||
console.error("Failed to fetch VS Code LM models:", error);
|
||||
setVsCodeLmModels([]);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
requestVsCodeLmModels()
|
||||
}, [requestVsCodeLmModels])
|
||||
requestVsCodeLmModels();
|
||||
}, [requestVsCodeLmModels]);
|
||||
|
||||
useInterval(requestVsCodeLmModels, 2000)
|
||||
useInterval(requestVsCodeLmModels, 2000);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropdownContainer className="dropdown-container" zIndex={DROPDOWN_Z_INDEX - 2}>
|
||||
<DropdownContainer
|
||||
className="dropdown-container"
|
||||
zIndex={DROPDOWN_Z_INDEX - 2}
|
||||
>
|
||||
<label htmlFor="vscode-lm-model">
|
||||
<span style={{ fontWeight: 500 }}>Language Model</span>
|
||||
</label>
|
||||
@@ -50,30 +64,36 @@ export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
|
||||
<VSCodeDropdown
|
||||
id="vscode-lm-model"
|
||||
onChange={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
if (!value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const [vendor, family] = value.split("/")
|
||||
|
||||
handleModeFieldChange(
|
||||
{ plan: "planModeVsCodeLmModelSelector", act: "actModeVsCodeLmModelSelector" },
|
||||
{ vendor, family },
|
||||
{
|
||||
plan: "planModeVsCodeLmModelSelector",
|
||||
act: "actModeVsCodeLmModelSelector",
|
||||
},
|
||||
parseVsCodeLmModelSelector(value),
|
||||
currentMode,
|
||||
)
|
||||
);
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
value={
|
||||
vsCodeLmModelSelector
|
||||
? `${vsCodeLmModelSelector.vendor ?? ""}/${vsCodeLmModelSelector.family ?? ""}`
|
||||
? stringifyVsCodeLmModelSelector(vsCodeLmModelSelector)
|
||||
: ""
|
||||
}>
|
||||
}
|
||||
>
|
||||
<VSCodeOption value="">Select a model...</VSCodeOption>
|
||||
{vsCodeLmModels.map((model) => (
|
||||
<VSCodeOption key={`${model.vendor}/${model.family}`} value={`${model.vendor}/${model.family}`}>
|
||||
{model.vendor} - {model.family}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
{vsCodeLmModels.map((model) => {
|
||||
const value = stringifyVsCodeLmModelSelector(model);
|
||||
return (
|
||||
<VSCodeOption key={value} value={value}>
|
||||
{model.vendor} - {model.family}
|
||||
</VSCodeOption>
|
||||
);
|
||||
})}
|
||||
</VSCodeDropdown>
|
||||
) : (
|
||||
<p
|
||||
@@ -81,13 +101,19 @@ export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Use models from your GitHub Copilot subscription. Install the{" "}
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot">Copilot extension</a> and
|
||||
enable Claude models in Copilot settings to get started.
|
||||
}}
|
||||
>
|
||||
Uses models contributed by other extensions through the VS Code
|
||||
Language Model API. The most common source is GitHub Copilot —
|
||||
install the{" "}
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot">
|
||||
Copilot extension
|
||||
</a>{" "}
|
||||
and enable models in Copilot settings — but any extension that
|
||||
registers a language model provider will appear here.
|
||||
</p>
|
||||
)}
|
||||
</DropdownContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ApiConfiguration, ApiProvider } from "@shared/api"
|
||||
import PROVIDERS from "@shared/providers/providers.json"
|
||||
import type { RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
|
||||
/**
|
||||
* Returns a list of API providers that are configured (have required credentials/settings)
|
||||
@@ -215,8 +216,11 @@ export function getConfiguredProviders(
|
||||
configured.push("litellm")
|
||||
}
|
||||
|
||||
// VSCode LM - always potentially available
|
||||
configured.push("vscode-lm")
|
||||
// VSCode LM - VS Code only (the vscode.lm API does not exist on other hosts
|
||||
// such as JetBrains). Potentially available there whenever models are installed.
|
||||
if (PLATFORM_CONFIG.type === PlatformType.VSCODE) {
|
||||
configured.push("vscode-lm")
|
||||
}
|
||||
|
||||
// Claude Code - requires path
|
||||
if (apiConfiguration.claudeCodePath) {
|
||||
|
||||
Reference in New Issue
Block a user