Compare commits

...

1 Commits

Author SHA1 Message Date
Ocasta 6e940ab4b2 initial pass 2025-02-19 00:29:53 -08:00
14 changed files with 1834 additions and 3561 deletions
+103
View File
@@ -0,0 +1,103 @@
import * as vscode from "vscode"
export type ApiProvider =
| "openai"
| "anthropic"
| "azure"
| "mistral"
| "deepseek"
| "qwen"
| "together"
| "litellm"
| "ollama"
| "lmstudio"
| "vertex"
| "requesty"
| "openrouter"
| "aws"
| "bedrock"
| "gemini"
| "openai-native"
| "vscode-lm"
export interface ModelInfo {
id: string
name: string
provider: string
contextWindow?: number
maxTokens?: number
supportsPromptCache?: boolean
supportsImages?: boolean
supportsComputerUse?: boolean
cacheWritesPrice?: number
cacheReadsPrice?: number
inputPrice?: number
outputPrice?: number
description?: string
}
export interface ApiHandlerOptions {
provider: ApiProvider
modelId: string
apiKey?: string
baseUrl?: string
}
export type AnthropicModelId = "claude-3-opus-20240229" | "claude-3-sonnet-20240229" | "claude-3-haiku-20240307"
export type BedrockModelId = "anthropic.claude-3-sonnet-20240229" | "anthropic.claude-3-haiku-20240307"
export type DeepSeekModelId = "deepseek-chat"
export type GeminiModelId = "gemini-pro" | "gemini-pro-vision"
export type OpenAiNativeModelId = "gpt-4-turbo-preview" | "gpt-4-vision-preview"
export type QwenModelId = "qwen-turbo" | "qwen-plus" | "qwen-max"
export type VertexModelId = "gemini-pro" | "gemini-pro-vision"
export interface ApiConfiguration {
apiModelId?: string
apiProvider: ApiProvider
apiKey?: string
openRouterApiKey?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
awsAccessKey?: string
awsSecretKey?: string
awsSessionToken?: string
awsRegion?: string
awsUseCrossRegionInference?: boolean
awsProfile?: string
awsUseProfile?: boolean
anthropicApiKey?: string
anthropicBaseUrl?: string
azureApiKey?: string
azureEndpoint?: string
azureDeploymentName?: string
azureApiVersion?: string
mistralApiKey?: string
deepSeekApiKey?: string
qwenApiKey?: string
qwenEndpoint?: string
qwenApiLine?: string
togetherApiKey?: string
togetherModelId?: string
liteLlmApiKey?: string
liteLlmBaseUrl?: string
liteLlmModelId?: string
ollamaBaseUrl?: string
ollamaModelId?: string
lmStudioBaseUrl?: string
lmStudioModelId?: string
vertexProjectId?: string
vertexLocation?: string
vertexEndpoint?: string
vertexModelId?: string
vertexRegion?: string
requestyApiKey?: string
requestyEndpoint?: string
requestyModelId?: string
openAiBaseUrl?: string
openAiApiKey?: string
openAiModelId?: string
openAiModelInfo?: ModelInfo
openAiNativeApiKey?: string
geminiApiKey?: string
vsCodeLmModelSelector?: string
}
+247
View File
@@ -0,0 +1,247 @@
import * as vscode from "vscode"
import { SecretKey, GlobalStateKey } from "../../types/state"
import { ApiProvider, ModelInfo } from "../../shared/api"
import { HistoryItem } from "../../shared/HistoryItem"
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
import { UserInfo } from "../../services/auth/FirebaseAuthManager"
export class StateManager {
constructor(private context: vscode.ExtensionContext) {}
async updateGlobalState(key: GlobalStateKey, value: any) {
await this.context.globalState.update(key, value)
}
async getGlobalState(key: GlobalStateKey) {
return await this.context.globalState.get(key)
}
async storeSecret(key: SecretKey, value?: string) {
if (value) {
await this.context.secrets.store(key, value)
} else {
await this.context.secrets.delete(key)
}
}
async getSecret(key: SecretKey) {
return await this.context.secrets.get(key)
}
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || []
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
history[existingItemIndex] = item
} else {
history.push(item)
}
await this.updateGlobalState("taskHistory", history)
return history
}
async resetState() {
for (const key of this.context.globalState.keys()) {
await this.context.globalState.update(key, undefined)
}
const secretKeys: SecretKey[] = [
"apiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"requestyApiKey",
"togetherApiKey",
"qwenApiKey",
"mistralApiKey",
"liteLlmApiKey",
"authToken",
]
for (const key of secretKeys) {
await this.storeSecret(key, undefined)
}
}
async getState() {
const [
storedApiProvider,
apiModelId,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings,
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
userInfo,
authToken,
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
qwenApiLine,
liteLlmApiKey,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId"),
this.getSecret("apiKey"),
this.getSecret("openRouterApiKey"),
this.getSecret("awsAccessKey"),
this.getSecret("awsSecretKey"),
this.getSecret("awsSessionToken"),
this.getGlobalState("awsRegion"),
this.getGlobalState("awsUseCrossRegionInference"),
this.getGlobalState("awsProfile"),
this.getGlobalState("awsUseProfile"),
this.getGlobalState("vertexProjectId"),
this.getGlobalState("vertexRegion"),
this.getGlobalState("openAiBaseUrl"),
this.getSecret("openAiApiKey"),
this.getGlobalState("openAiModelId"),
this.getGlobalState("openAiModelInfo"),
this.getGlobalState("ollamaModelId"),
this.getGlobalState("ollamaBaseUrl"),
this.getGlobalState("lmStudioModelId"),
this.getGlobalState("lmStudioBaseUrl"),
this.getGlobalState("anthropicBaseUrl"),
this.getSecret("geminiApiKey"),
this.getSecret("openAiNativeApiKey"),
this.getSecret("deepSeekApiKey"),
this.getSecret("requestyApiKey"),
this.getGlobalState("requestyModelId"),
this.getSecret("togetherApiKey"),
this.getGlobalState("togetherModelId"),
this.getSecret("qwenApiKey"),
this.getSecret("mistralApiKey"),
this.getGlobalState("azureApiVersion"),
this.getGlobalState("openRouterModelId"),
this.getGlobalState("openRouterModelInfo"),
this.getGlobalState("lastShownAnnouncementId"),
this.getGlobalState("customInstructions"),
this.getGlobalState("taskHistory"),
this.getGlobalState("autoApprovalSettings"),
this.getGlobalState("browserSettings"),
this.getGlobalState("chatSettings"),
this.getGlobalState("vsCodeLmModelSelector"),
this.getGlobalState("liteLlmBaseUrl"),
this.getGlobalState("liteLlmModelId"),
this.getGlobalState("userInfo"),
this.getSecret("authToken"),
this.getGlobalState("previousModeApiProvider"),
this.getGlobalState("previousModeModelId"),
this.getGlobalState("previousModeModelInfo"),
this.getGlobalState("qwenApiLine"),
this.getSecret("liteLlmApiKey"),
])
let apiProvider: ApiProvider
if (storedApiProvider) {
apiProvider = storedApiProvider
} else {
if (apiKey) {
apiProvider = "anthropic"
} else {
apiProvider = "openrouter"
}
}
const o3MiniReasoningEffort = vscode.workspace
.getConfiguration("cline.modelSettings.o3Mini")
.get("reasoningEffort", "medium")
return {
apiConfiguration: {
apiProvider,
apiModelId,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
qwenApiLine,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
vsCodeLmModelSelector,
o3MiniReasoningEffort,
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
},
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS,
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
authToken,
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
}
}
}
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
import * as vscode from "vscode"
import { Cline } from "../Cline"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { FirebaseAuthManager } from "../../services/auth/FirebaseAuthManager"
import { StateManager } from "../state/StateManager"
import { WebviewMessageHandler } from "./WebviewMessageHandler"
import { IClineProvider } from "./IClineProvider"
import { ExtensionMessage, ExtensionState } from "../../shared/ExtensionMessage"
import { GlobalStateKey, SecretKey } from "../../types/state"
import { HistoryItem } from "../../shared/HistoryItem"
import { ApiConfiguration } from "../../api/types"
export abstract class ClineProviderBase implements vscode.WebviewViewProvider, IClineProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider"
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<IClineProvider> = new Set()
protected disposables: vscode.Disposable[] = []
protected view?: vscode.WebviewView | vscode.WebviewPanel
protected cline?: Cline
workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
protected stateManager: StateManager
protected messageHandler: WebviewMessageHandler
protected authManager: FirebaseAuthManager
readonly latestAnnouncementId = "jan-20-2025"
constructor(
readonly context: vscode.ExtensionContext,
protected readonly outputChannel: vscode.OutputChannel,
) {
this.outputChannel.appendLine("ClineProvider instantiated")
ClineProviderBase.activeInstances.add(this)
this.stateManager = new StateManager(context)
this.messageHandler = new WebviewMessageHandler(this, this.stateManager)
// Initialize these after messageHandler since they depend on IClineProvider
this.workspaceTracker = new WorkspaceTracker(this)
this.mcpHub = new McpHub(this)
this.authManager = new FirebaseAuthManager(this)
}
// Methods that need to be accessible to WebviewMessageHandler
getCline(): Cline | undefined {
return this.cline
}
setCline(cline: Cline | undefined) {
this.cline = cline
}
getLatestAnnouncementId(): string {
return this.latestAnnouncementId
}
// Required abstract methods that must be implemented by ClineProvider
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): void | Thenable<void>
abstract dispose(): Promise<void>
abstract handleSignOut(): Promise<void>
abstract setAuthToken(token?: string): Promise<void>
abstract setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }): Promise<void>
abstract postMessageToWebview(message: ExtensionMessage): Promise<void>
abstract postStateToWebview(): Promise<void>
abstract getState(): Promise<{
apiConfiguration: ApiConfiguration
lastShownAnnouncementId?: string
customInstructions?: string
taskHistory?: HistoryItem[]
autoApprovalSettings: any
browserSettings: any
chatSettings: any
userInfo?: any
authToken?: string
}>
abstract updateGlobalState(key: GlobalStateKey, value: any): Promise<void>
abstract getGlobalState(key: GlobalStateKey): Promise<any>
abstract storeSecret(key: SecretKey, value?: string): Promise<void>
abstract getSecret(key: SecretKey): Promise<any>
// Required method for task management
abstract clearTask(): Promise<void>
// Additional required abstract methods
abstract initClineWithTask(task?: string, images?: string[]): Promise<void>
abstract initClineWithHistoryItem(historyItem: HistoryItem): Promise<void>
abstract updateCustomInstructions(instructions?: string): Promise<void>
abstract cancelTask(): Promise<void>
abstract getTaskWithId(id: string): Promise<{
historyItem: HistoryItem
taskDirPath: string
apiConversationHistoryFilePath: string
uiMessagesFilePath: string
apiConversationHistory: any[]
}>
abstract deleteTaskWithId(id: string): Promise<void>
// Protected abstract methods that ClineProvider must implement
protected abstract fileExists(path: string): Promise<boolean>
protected abstract deleteTaskFromState(id: string): Promise<void>
// Public abstract methods that ClineProvider must implement
abstract getStateToWebview(): Promise<ExtensionState>
protected abstract getHtmlContent(webview: vscode.Webview): string
}
+51
View File
@@ -0,0 +1,51 @@
import * as vscode from "vscode"
import { Cline } from "../Cline"
import { GlobalStateKey, SecretKey } from "../../types/state"
import { HistoryItem } from "../../shared/HistoryItem"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { ApiConfiguration } from "../../shared/api"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
export interface IClineProvider {
workspaceTracker?: WorkspaceTracker
readonly context: vscode.ExtensionContext
getCline(): Cline | undefined
setCline(cline: Cline | undefined): void
getLatestAnnouncementId(): string
dispose(): Promise<void>
handleSignOut(): Promise<void>
setAuthToken(token?: string): Promise<void>
setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }): Promise<void>
postMessageToWebview(message: ExtensionMessage): Promise<void>
postStateToWebview(): Promise<void>
getState(): Promise<{
apiConfiguration: ApiConfiguration
lastShownAnnouncementId?: string
customInstructions?: string
taskHistory?: HistoryItem[]
autoApprovalSettings: any
browserSettings: any
chatSettings: any
userInfo?: any
authToken?: string
}>
updateGlobalState(key: GlobalStateKey, value: any): Promise<void>
getGlobalState(key: GlobalStateKey): Promise<any>
storeSecret(key: SecretKey, value?: string): Promise<void>
getSecret(key: SecretKey): Promise<any>
// Additional required methods
clearTask(): Promise<void>
initClineWithTask(task?: string, images?: string[]): Promise<void>
initClineWithHistoryItem(historyItem: HistoryItem): Promise<void>
updateCustomInstructions(instructions?: string): Promise<void>
cancelTask(): Promise<void>
getTaskWithId(id: string): Promise<{
historyItem: HistoryItem
taskDirPath: string
apiConversationHistoryFilePath: string
uiMessagesFilePath: string
apiConversationHistory: any[]
}>
deleteTaskWithId(id: string): Promise<void>
}
+365
View File
@@ -0,0 +1,365 @@
import * as vscode from "vscode"
import { WebviewMessage, ClineCheckpointRestore } from "../../shared/WebviewMessage"
import { StateManager } from "../state/StateManager"
import { IClineProvider } from "./IClineProvider"
import { buildApiHandler } from "../../api"
import { selectImages } from "../../integrations/misc/process-images"
import { openFile, openImage } from "../../integrations/misc/open-file"
import { openMention } from "../mentions"
import { downloadTask } from "../../integrations/misc/export-markdown"
import { searchCommits } from "../../utils/git"
import { getTheme } from "../../integrations/theme/getTheme"
import pWaitFor from "p-wait-for"
import crypto from "crypto"
import { ApiConfiguration } from "../../shared/api"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
export class WebviewMessageHandler {
constructor(
private provider: IClineProvider,
private stateManager: StateManager,
) {}
async handleMessage(message: WebviewMessage) {
switch (message.type) {
case "webviewDidLaunch":
await this.handleWebviewLaunch()
break
case "newTask":
await this.handleNewTask(message)
break
case "apiConfiguration":
await this.handleApiConfiguration(message)
break
case "customInstructions":
await this.handleCustomInstructions(message)
break
case "autoApprovalSettings":
await this.handleAutoApprovalSettings(message)
break
case "browserSettings":
await this.handleBrowserSettings(message)
break
case "chatSettings":
await this.handleChatSettings(message)
break
case "askResponse":
await this.handleAskResponse(message)
break
case "clearTask":
await this.handleClearTask()
break
case "didShowAnnouncement":
await this.handleDidShowAnnouncement()
break
case "selectImages":
await this.handleSelectImages()
break
case "exportCurrentTask":
await this.handleExportCurrentTask()
break
case "showTaskWithId":
await this.handleShowTaskWithId(message)
break
case "deleteTaskWithId":
await this.handleDeleteTaskWithId(message)
break
case "exportTaskWithId":
await this.handleExportTaskWithId(message)
break
case "resetState":
await this.handleResetState()
break
case "openImage":
openImage(message.text!)
break
case "openFile":
openFile(message.text!)
break
case "openMention":
openMention(message.text)
break
case "checkpointDiff":
await this.handleCheckpointDiff(message)
break
case "checkpointRestore":
await this.handleCheckpointRestore(message)
break
case "taskCompletionViewChanges":
await this.handleTaskCompletionViewChanges(message)
break
case "cancelTask":
await this.provider.cancelTask()
break
case "getLatestState":
await this.provider.postStateToWebview()
break
case "accountLoginClicked":
await this.handleAccountLoginClicked()
break
case "accountLogoutClicked":
await this.provider.handleSignOut()
break
case "searchCommits":
await this.handleSearchCommits(message)
break
case "openExtensionSettings":
await this.handleOpenExtensionSettings(message)
break
}
}
private async handleWebviewLaunch() {
await this.provider.postStateToWebview()
this.provider.workspaceTracker?.populateFilePaths()
const theme = await getTheme()
await this.provider.postMessageToWebview({
type: "theme",
text: JSON.stringify(theme),
})
}
private async handleNewTask(message: WebviewMessage) {
await this.provider.initClineWithTask(message.text, message.images)
}
private async handleApiConfiguration(message: WebviewMessage) {
if (message.apiConfiguration) {
const config = message.apiConfiguration as ApiConfiguration
await this.stateManager.updateGlobalState("apiProvider", config.apiProvider)
await this.stateManager.updateGlobalState("apiModelId", config.apiModelId)
await this.stateManager.storeSecret("apiKey", config.apiKey)
await this.stateManager.storeSecret("openRouterApiKey", config.openRouterApiKey)
await this.stateManager.storeSecret("awsAccessKey", config.awsAccessKey)
await this.stateManager.storeSecret("awsSecretKey", config.awsSecretKey)
await this.stateManager.storeSecret("awsSessionToken", config.awsSessionToken)
await this.stateManager.updateGlobalState("awsRegion", config.awsRegion)
await this.stateManager.updateGlobalState("awsUseCrossRegionInference", config.awsUseCrossRegionInference)
await this.stateManager.updateGlobalState("awsProfile", config.awsProfile)
await this.stateManager.updateGlobalState("awsUseProfile", config.awsUseProfile)
await this.stateManager.updateGlobalState("vertexProjectId", config.vertexProjectId)
await this.stateManager.updateGlobalState("vertexRegion", config.vertexRegion)
await this.stateManager.updateGlobalState("openAiBaseUrl", config.openAiBaseUrl)
await this.stateManager.storeSecret("openAiApiKey", config.openAiApiKey)
await this.stateManager.updateGlobalState("openAiModelId", config.openAiModelId)
await this.stateManager.updateGlobalState("openAiModelInfo", config.openAiModelInfo)
await this.stateManager.updateGlobalState("ollamaModelId", config.ollamaModelId)
await this.stateManager.updateGlobalState("ollamaBaseUrl", config.ollamaBaseUrl)
await this.stateManager.updateGlobalState("lmStudioModelId", config.lmStudioModelId)
await this.stateManager.updateGlobalState("lmStudioBaseUrl", config.lmStudioBaseUrl)
await this.stateManager.updateGlobalState("anthropicBaseUrl", config.anthropicBaseUrl)
await this.stateManager.storeSecret("geminiApiKey", config.geminiApiKey)
await this.stateManager.storeSecret("openAiNativeApiKey", config.openAiNativeApiKey)
await this.stateManager.storeSecret("deepSeekApiKey", config.deepSeekApiKey)
await this.stateManager.storeSecret("requestyApiKey", config.requestyApiKey)
await this.stateManager.storeSecret("togetherApiKey", config.togetherApiKey)
await this.stateManager.storeSecret("qwenApiKey", config.qwenApiKey)
await this.stateManager.storeSecret("mistralApiKey", config.mistralApiKey)
await this.stateManager.updateGlobalState("azureApiVersion", config.azureApiVersion)
await this.stateManager.updateGlobalState("openRouterModelId", config.openRouterModelId)
await this.stateManager.updateGlobalState("openRouterModelInfo", config.openRouterModelInfo)
await this.stateManager.updateGlobalState("vsCodeLmModelSelector", config.vsCodeLmModelSelector)
await this.stateManager.updateGlobalState("liteLlmBaseUrl", config.liteLlmBaseUrl)
await this.stateManager.updateGlobalState("liteLlmModelId", config.liteLlmModelId)
await this.stateManager.storeSecret("liteLlmApiKey", config.liteLlmApiKey)
await this.stateManager.updateGlobalState("qwenApiLine", config.qwenApiLine)
await this.stateManager.updateGlobalState("requestyModelId", config.requestyModelId)
await this.stateManager.updateGlobalState("togetherModelId", config.togetherModelId)
if (this.provider.getCline()) {
this.provider.getCline()!.api = buildApiHandler(message.apiConfiguration)
}
}
await this.provider.postStateToWebview()
}
private async handleCustomInstructions(message: WebviewMessage) {
await this.provider.updateCustomInstructions(message.text)
}
private async handleAutoApprovalSettings(message: WebviewMessage) {
if (message.autoApprovalSettings) {
await this.stateManager.updateGlobalState("autoApprovalSettings", message.autoApprovalSettings)
const cline = this.provider.getCline()
if (cline) {
cline.autoApprovalSettings = message.autoApprovalSettings
}
await this.provider.postStateToWebview()
}
}
private async handleBrowserSettings(message: WebviewMessage) {
if (message.browserSettings) {
await this.stateManager.updateGlobalState("browserSettings", message.browserSettings)
const cline = this.provider.getCline()
if (cline) {
cline.updateBrowserSettings(message.browserSettings)
}
await this.provider.postStateToWebview()
}
}
private async handleChatSettings(message: WebviewMessage) {
if (message.chatSettings) {
const didSwitchToActMode = message.chatSettings.mode === "act"
await this.stateManager.updateGlobalState("chatSettings", message.chatSettings)
const cline = this.provider.getCline()
if (cline) {
cline.updateChatSettings(message.chatSettings)
if (cline.isAwaitingPlanResponse && didSwitchToActMode) {
cline.didRespondToPlanAskBySwitchingMode = true
await this.provider.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
text: message.chatContent?.message || "PLAN_MODE_TOGGLE_RESPONSE",
images: message.chatContent?.images,
})
} else {
this.provider.cancelTask()
}
}
await this.provider.postStateToWebview()
}
}
private async handleAskResponse(message: WebviewMessage) {
const cline = this.provider.getCline()
if (cline) {
cline.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
}
}
private async handleClearTask() {
await this.provider.clearTask()
await this.provider.postStateToWebview()
}
private async handleDidShowAnnouncement() {
await this.stateManager.updateGlobalState("lastShownAnnouncementId", this.provider.getLatestAnnouncementId())
await this.provider.postStateToWebview()
}
private async handleSelectImages() {
const images = await selectImages()
await this.provider.postMessageToWebview({
type: "selectedImages",
images,
})
}
private async handleExportCurrentTask() {
const currentTaskId = this.provider.getCline()?.taskId
if (currentTaskId) {
await this.handleExportTaskWithId({ type: "exportTaskWithId", text: currentTaskId })
}
}
private async handleShowTaskWithId(message: WebviewMessage) {
const cline = this.provider.getCline()
if (message.text !== cline?.taskId) {
const { historyItem } = await this.provider.getTaskWithId(message.text!)
await this.provider.initClineWithHistoryItem(historyItem)
}
await this.provider.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
}
private async handleDeleteTaskWithId(message: WebviewMessage) {
await this.provider.deleteTaskWithId(message.text!)
}
private async handleExportTaskWithId(message: WebviewMessage) {
const { historyItem, apiConversationHistory } = await this.provider.getTaskWithId(message.text!)
await downloadTask(historyItem.ts, apiConversationHistory)
}
private async handleResetState() {
await this.stateManager.resetState()
const cline = this.provider.getCline()
if (cline) {
cline.abortTask()
this.provider.setCline(undefined)
}
await this.provider.postStateToWebview()
await this.provider.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
}
private async handleCheckpointDiff(message: WebviewMessage) {
if (message.number) {
const cline = this.provider.getCline()
if (cline) {
await cline.presentMultifileDiff(message.number, false)
}
}
}
private async handleCheckpointRestore(message: WebviewMessage) {
await this.provider.cancelTask()
if (message.number) {
const cline = this.provider.getCline()
await pWaitFor(() => cline?.isInitialized === true, {
timeout: 3_000,
}).catch(() => {
console.error("Failed to init new cline instance")
})
if (cline) {
const restore: ClineCheckpointRestore = {
checkpointNumber: message.number,
restoreMode: (message.text || "task") as "task" | "workspace" | "taskAndWorkspace",
}
await cline.restoreCheckpoint(message.number, restore)
}
}
}
private async handleTaskCompletionViewChanges(message: WebviewMessage) {
if (message.number) {
const cline = this.provider.getCline()
if (cline) {
await cline.presentMultifileDiff(message.number, true)
}
}
}
private async handleAccountLoginClicked() {
const nonce = crypto.randomBytes(32).toString("hex")
await this.stateManager.storeSecret("authNonce", nonce)
const uriScheme = vscode.env.uriScheme
const authUrl = vscode.Uri.parse(
`https://app.cline.bot/auth?state=${encodeURIComponent(nonce)}&callback_url=${encodeURIComponent(
`${uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`,
)}`,
)
vscode.env.openExternal(authUrl)
}
private async handleSearchCommits(message: WebviewMessage) {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (cwd) {
try {
const commits = await searchCommits(message.text || "", cwd)
await this.provider.postMessageToWebview({
type: "commitSearchResults",
commits,
})
} catch (error) {
console.error(`Error searching commits: ${JSON.stringify(error)}`)
}
}
}
private async handleOpenExtensionSettings(message: WebviewMessage) {
const settingsFilter = message.text || ""
await vscode.commands.executeCommand(
"workbench.action.openSettings",
`@ext:saoudrizwan.claude-dev ${settingsFilter}`.trim(),
)
}
}
+37 -117
View File
@@ -1,127 +1,47 @@
import * as vscode from "vscode"
import * as path from "path"
import { listFiles } from "../../services/glob/list-files"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { IClineProvider } from "../../core/webview/IClineProvider"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
class WorkspaceTracker {
private providerRef: WeakRef<ClineProvider>
export default class WorkspaceTracker {
private disposables: vscode.Disposable[] = []
private filePaths: Set<string> = new Set()
private fileWatcher?: vscode.FileSystemWatcher
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
this.registerListeners()
constructor(private provider: IClineProvider) {
this.setupFileWatcher()
}
dispose() {
if (this.fileWatcher) {
this.fileWatcher.dispose()
}
while (this.disposables.length) {
const x = this.disposables.pop()
if (x) {
x.dispose()
}
}
}
private setupFileWatcher() {
const workspaceFolders = vscode.workspace.workspaceFolders
if (workspaceFolders) {
this.fileWatcher = vscode.workspace.createFileSystemWatcher("**/*")
this.fileWatcher.onDidChange(() => this.populateFilePaths())
this.fileWatcher.onDidCreate(() => this.populateFilePaths())
this.fileWatcher.onDidDelete(() => this.populateFilePaths())
this.disposables.push(this.fileWatcher)
}
}
async populateFilePaths() {
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
if (!cwd) {
return
const workspaceFolders = vscode.workspace.workspaceFolders
if (workspaceFolders) {
const message: ExtensionMessage = {
type: "workspaceUpdated",
workspace: vscode.workspace.name || "",
workspaceFolders: workspaceFolders.map((folder) => folder.uri.fsPath),
}
await this.provider.postMessageToWebview(message)
}
const [files, _] = await listFiles(cwd, true, 1_000)
files.forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
this.workspaceDidUpdate()
}
private registerListeners() {
// Listen for file creation
// .bind(this) ensures the callback refers to class instance when using this, not necessary when using arrow function
this.disposables.push(vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this)))
// Listen for file deletion
this.disposables.push(vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this)))
// Listen for file renaming
this.disposables.push(vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this)))
/*
An event that is emitted when a workspace folder is added or removed.
**Note:** this event will not fire if the first workspace folder is added, removed or changed,
because in that case the currently executing extensions (including the one that listens to this
event) will be terminated and restarted so that the (deprecated) `rootPath` property is updated
to point to the first workspace folder.
*/
// In other words, we don't have to worry about the root workspace folder ([0]) changing since the extension will be restarted and our cwd will be updated to reflect the new workspace folder. (We don't care about non root workspace folders, since cline will only be working within the root folder cwd)
// this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this)))
}
private async onFilesCreated(event: vscode.FileCreateEvent) {
await Promise.all(
event.files.map(async (file) => {
await this.addFilePath(file.fsPath)
}),
)
this.workspaceDidUpdate()
}
private async onFilesDeleted(event: vscode.FileDeleteEvent) {
let updated = false
await Promise.all(
event.files.map(async (file) => {
if (await this.removeFilePath(file.fsPath)) {
updated = true
}
}),
)
if (updated) {
this.workspaceDidUpdate()
}
}
private async onFilesRenamed(event: vscode.FileRenameEvent) {
await Promise.all(
event.files.map(async (file) => {
await this.removeFilePath(file.oldUri.fsPath)
await this.addFilePath(file.newUri.fsPath)
}),
)
this.workspaceDidUpdate()
}
private workspaceDidUpdate() {
if (!cwd) {
return
}
this.providerRef.deref()?.postMessageToWebview({
type: "workspaceUpdated",
filePaths: Array.from(this.filePaths).map((file) => {
const relativePath = path.relative(cwd, file).toPosix()
return file.endsWith("/") ? relativePath + "/" : relativePath
}),
})
}
private normalizeFilePath(filePath: string): string {
const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
}
private async addFilePath(filePath: string): Promise<string> {
const normalizedPath = this.normalizeFilePath(filePath)
try {
const stat = await vscode.workspace.fs.stat(vscode.Uri.file(normalizedPath))
const isDirectory = (stat.type & vscode.FileType.Directory) !== 0
const pathWithSlash = isDirectory && !normalizedPath.endsWith("/") ? normalizedPath + "/" : normalizedPath
this.filePaths.add(pathWithSlash)
return pathWithSlash
} catch {
// If stat fails, assume it's a file (this can happen for newly created files)
this.filePaths.add(normalizedPath)
return normalizedPath
}
}
private async removeFilePath(filePath: string): Promise<boolean> {
const normalizedPath = this.normalizeFilePath(filePath)
return this.filePaths.delete(normalizedPath) || this.filePaths.delete(normalizedPath + "/")
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
}
}
export default WorkspaceTracker
+14 -82
View File
@@ -1,8 +1,5 @@
import { initializeApp } from "firebase/app"
import { Auth, User, getAuth, onAuthStateChanged, signInWithCustomToken, signOut } from "firebase/auth"
import * as vscode from "vscode"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { firebaseConfig } from "./config"
import { IClineProvider } from "../../core/webview/IClineProvider"
export interface UserInfo {
displayName: string | null
@@ -11,89 +8,24 @@ export interface UserInfo {
}
export class FirebaseAuthManager {
private providerRef: WeakRef<ClineProvider>
private auth: Auth
private disposables: vscode.Disposable[] = []
constructor(private provider: IClineProvider) {}
constructor(provider: ClineProvider) {
console.log("Initializing FirebaseAuthManager", { provider })
this.providerRef = new WeakRef(provider)
const app = initializeApp(firebaseConfig)
this.auth = getAuth(app)
console.log("Firebase app initialized", { appConfig: firebaseConfig })
// Auth state listener
onAuthStateChanged(this.auth, this.handleAuthStateChange.bind(this))
console.log("Auth state change listener added")
// Try to restore session
this.restoreSession()
}
private async restoreSession() {
console.log("Attempting to restore session")
const provider = this.providerRef.deref()
if (!provider) {
console.log("Provider reference lost during session restore")
return
}
const storedToken = await provider.getSecret("authToken")
if (storedToken) {
console.log("Found stored auth token, attempting to restore session")
try {
await this.signInWithCustomToken(storedToken)
console.log("Session restored successfully")
} catch (error) {
console.error("Failed to restore session, clearing token:", error)
await provider.setAuthToken(undefined)
await provider.setUserInfo(undefined)
}
} else {
console.log("No stored auth token found")
}
}
private async handleAuthStateChange(user: User | null) {
console.log("Auth state changed", { user })
const provider = this.providerRef.deref()
if (!provider) {
console.log("Provider reference lost")
return
}
if (user) {
console.log("User signed in", { userId: user.uid })
const idToken = await user.getIdToken()
await provider.setAuthToken(idToken)
// Store public user info in state
await provider.setUserInfo({
displayName: user.displayName,
email: user.email,
photoURL: user.photoURL,
})
console.log("User info set in provider", { user })
} else {
console.log("User signed out")
await provider.setAuthToken(undefined)
await provider.setUserInfo(undefined)
}
await provider.postStateToWebview()
console.log("Webview state updated")
}
async signInWithCustomToken(token: string) {
console.log("Signing in with custom token", { token })
await signInWithCustomToken(this.auth, token)
dispose() {
// Implementation
}
async signOut() {
console.log("Signing out")
await signOut(this.auth)
await this.provider.setAuthToken(undefined)
await this.provider.setUserInfo(undefined)
}
dispose() {
this.disposables.forEach((d) => d.dispose())
console.log("Disposables disposed", { count: this.disposables.length })
async signInWithCustomToken(token: string) {
await this.provider.setAuthToken(token)
// Implementation for getting user info would go here
await this.provider.setUserInfo({
displayName: null,
email: null,
photoURL: null,
})
}
}
+71 -635
View File
@@ -1,650 +1,86 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"
import {
CallToolResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListToolsResultSchema,
ReadResourceResultSchema,
} from "@modelcontextprotocol/sdk/types.js"
import chokidar, { FSWatcher } from "chokidar"
import delay from "delay"
import deepEqual from "fast-deep-equal"
import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
import {
McpMode,
McpResource,
McpResourceResponse,
McpResourceTemplate,
McpServer,
McpTool,
McpToolCallResponse,
} from "../../shared/mcp"
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { ModelInfo } from "../../shared/api"
import { IClineProvider } from "../../core/webview/IClineProvider"
export type McpConnection = {
server: McpServer
client: Client
transport: StdioClientTransport
export interface McpServer {
name: string
tools: McpTool[]
resources: McpResource[]
resourceTemplates: McpResourceTemplate[]
}
const AutoApproveSchema = z.array(z.string()).default([])
export interface McpTool {
name: string
description: string
inputSchema: any
autoApprove: boolean
}
// StdioServerParameters
const StdioConfigSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
autoApprove: AutoApproveSchema.optional(),
disabled: z.boolean().optional(),
})
export interface McpResource {
uri: string
name: string
description?: string
mimeType?: string
}
const McpSettingsSchema = z.object({
mcpServers: z.record(StdioConfigSchema),
})
export interface McpResourceTemplate {
uriTemplate: string
name: string
description?: string
mimeType?: string
}
export interface McpConnection {
server: McpServer
callTool: (toolName: string, args: any) => Promise<any>
readResource: (uri: string) => Promise<any>
}
export class McpHub {
private providerRef: WeakRef<ClineProvider>
private disposables: vscode.Disposable[] = []
private settingsWatcher?: vscode.FileSystemWatcher
private fileWatchers: Map<string, FSWatcher> = new Map()
connections: McpConnection[] = []
isConnecting: boolean = false
private connections: McpConnection[] = []
private isConnecting: boolean = false
private mode: "off" | "limited" | "full" = "off"
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
this.watchMcpSettingsFile()
this.initializeMcpServers()
constructor(private provider: IClineProvider) {
// Initialize MCP hub
}
getServers(): McpServer[] {
// Only return enabled servers
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
}
getMode(): McpMode {
return vscode.workspace.getConfiguration("cline.mcp").get<McpMode>("mode", "full")
}
async getMcpServersPath(): Promise<string> {
const provider = this.providerRef.deref()
if (!provider) {
throw new Error("Provider not available")
}
const mcpServersPath = await provider.ensureMcpServersDirectoryExists()
return mcpServersPath
}
async getMcpSettingsFilePath(): Promise<string> {
const provider = this.providerRef.deref()
if (!provider) {
throw new Error("Provider not available")
}
const mcpSettingsFilePath = path.join(await provider.ensureSettingsDirectoryExists(), GlobalFileNames.mcpSettings)
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
if (!fileExists) {
await fs.writeFile(
mcpSettingsFilePath,
`{
"mcpServers": {
}
}`,
)
}
return mcpSettingsFilePath
}
private async watchMcpSettingsFile(): Promise<void> {
const settingsPath = await this.getMcpSettingsFilePath()
this.disposables.push(
vscode.workspace.onDidSaveTextDocument(async (document) => {
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
const content = await fs.readFile(settingsPath, "utf-8")
const errorMessage =
"Invalid MCP settings format. Please ensure your settings follow the correct JSON format."
let config: any
try {
config = JSON.parse(content)
} catch (error) {
vscode.window.showErrorMessage(errorMessage)
return
}
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
vscode.window.showErrorMessage(errorMessage)
return
}
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(result.data.mcpServers || {})
vscode.window.showInformationMessage("MCP servers updated")
} catch (error) {
console.error("Failed to process MCP settings change:", error)
}
}
}),
)
}
private async initializeMcpServers(): Promise<void> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
await this.updateServerConnections(config.mcpServers || {})
} catch (error) {
console.error("Failed to initialize MCP servers:", error)
}
}
private async connectToServer(name: string, config: StdioServerParameters): Promise<void> {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
this.connections = this.connections.filter((conn) => conn.server.name !== name)
try {
// Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection.
const client = new Client(
{
name: "Cline",
version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
},
{
capabilities: {},
},
)
const transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: {
...config.env,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
// ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}),
},
stderr: "pipe", // necessary for stderr to be available
})
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error.message)
}
await this.notifyWebviewOfServerChanges()
}
transport.onclose = async () => {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
}
await this.notifyWebviewOfServerChanges()
}
// If the config is invalid, show an error
if (!StdioConfigSchema.safeParse(config).success) {
console.error(`Invalid config for "${name}": missing or invalid parameters`)
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "disconnected",
error: "Invalid config: missing or invalid parameters",
},
client,
transport,
}
this.connections.push(connection)
return
}
// valid schema
const parsedConfig = StdioConfigSchema.parse(config)
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "connecting",
disabled: parsedConfig.disabled,
},
client,
transport,
}
this.connections.push(connection)
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
await transport.start()
const stderrStream = transport.stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const errorOutput = data.toString()
console.error(`Server "${name}" stderr:`, errorOutput)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
// NOTE: we do not set server status to "disconnected" because stderr logs do not necessarily mean the server crashed or disconnected, it could just be informational. In fact when the server first starts up, it immediately logs "<name> server running on stdio" to stderr.
this.appendErrorMessage(connection, errorOutput)
// Only need to update webview right away if it's already disconnected
if (connection.server.status === "disconnected") {
await this.notifyWebviewOfServerChanges()
}
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
// // Set up notification handlers
// client.setNotificationHandler(
// // @ts-ignore-next-line
// { method: "notifications/tools/list_changed" },
// async () => {
// console.log(`Tools changed for server: ${name}`)
// connection.server.tools = await this.fetchTools(name)
// await this.notifyWebviewOfServerChanges()
// },
// )
// client.setNotificationHandler(
// // @ts-ignore-next-line
// { method: "notifications/resources/list_changed" },
// async () => {
// console.log(`Resources changed for server: ${name}`)
// connection.server.resources = await this.fetchResources(name)
// connection.server.resourceTemplates = await this.fetchResourceTemplates(name)
// await this.notifyWebviewOfServerChanges()
// },
// )
// Connect
await client.connect(transport)
connection.server.status = "connected"
connection.server.error = ""
// Initial fetch of tools and resources
connection.server.tools = await this.fetchToolsList(name)
connection.server.resources = await this.fetchResourcesList(name)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
} catch (error) {
// Update status with error
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error))
}
throw error
}
}
private appendErrorMessage(connection: McpConnection, error: string) {
const newError = connection.server.error ? `${connection.server.error}\n${error}` : error
connection.server.error = newError //.slice(0, 800)
}
private async fetchToolsList(serverName: string): Promise<McpTool[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
// Get autoApprove settings
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const autoApproveConfig = config.mcpServers[serverName]?.autoApprove || []
// Mark tools as always allowed based on settings
const tools = (response?.tools || []).map((tool) => ({
...tool,
autoApprove: autoApproveConfig.includes(tool.name),
}))
// console.log(`[MCP] Fetched tools for ${serverName}:`, tools)
return tools
} catch (error) {
// console.error(`Failed to fetch tools for ${serverName}:`, error)
return []
}
}
private async fetchResourcesList(serverName: string): Promise<McpResource[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/list" }, ListResourcesResultSchema)
return response?.resources || []
} catch (error) {
// console.error(`Failed to fetch resources for ${serverName}:`, error)
return []
}
}
private async fetchResourceTemplatesList(serverName: string): Promise<McpResourceTemplate[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema)
return response?.resourceTemplates || []
} catch (error) {
// console.error(`Failed to fetch resource templates for ${serverName}:`, error)
return []
}
}
async deleteConnection(name: string): Promise<void> {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
try {
// connection.client.removeNotificationHandler("notifications/tools/list_changed")
// connection.client.removeNotificationHandler("notifications/resources/list_changed")
// connection.client.removeNotificationHandler("notifications/stderr")
// connection.client.removeNotificationHandler("notifications/stderr")
await connection.transport.close()
await connection.client.close()
} catch (error) {
console.error(`Failed to close transport for ${name}:`, error)
}
this.connections = this.connections.filter((conn) => conn.server.name !== name)
}
}
async updateServerConnections(newServers: Record<string, any>): Promise<void> {
this.isConnecting = true
this.removeAllFileWatchers()
const currentNames = new Set(this.connections.map((conn) => conn.server.name))
const newNames = new Set(Object.keys(newServers))
// Delete removed servers
for (const name of currentNames) {
if (!newNames.has(name)) {
await this.deleteConnection(name)
console.log(`Deleted MCP server: ${name}`)
}
}
// Update or add servers
for (const [name, config] of Object.entries(newServers)) {
const currentConnection = this.connections.find((conn) => conn.server.name === name)
if (!currentConnection) {
// New server
try {
this.setupFileWatcher(name, config)
await this.connectToServer(name, config)
} catch (error) {
console.error(`Failed to connect to new MCP server ${name}:`, error)
}
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
// Existing server with changed config
try {
this.setupFileWatcher(name, config)
await this.deleteConnection(name)
await this.connectToServer(name, config)
console.log(`Reconnected MCP server with updated config: ${name}`)
} catch (error) {
console.error(`Failed to reconnect MCP server ${name}:`, error)
}
}
// If server exists with same config, do nothing
}
await this.notifyWebviewOfServerChanges()
this.isConnecting = false
}
private setupFileWatcher(name: string, config: any) {
const filePath = config.args?.find((arg: string) => arg.includes("build/index.js"))
if (filePath) {
// we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor. The settings config is better suited for onDidSave since that will be manually updated by the user or Cline (and we want to detect save events, not every file change)
const watcher = chokidar.watch(filePath, {
// persistent: true,
// ignoreInitial: true,
// awaitWriteFinish: true, // This helps with atomic writes
})
watcher.on("change", () => {
console.log(`Detected change in ${filePath}. Restarting server ${name}...`)
this.restartConnection(name)
})
this.fileWatchers.set(name, watcher)
}
}
private removeAllFileWatchers() {
this.fileWatchers.forEach((watcher) => watcher.close())
this.fileWatchers.clear()
}
async restartConnection(serverName: string): Promise<void> {
this.isConnecting = true
const provider = this.providerRef.deref()
if (!provider) {
return
}
// Get existing connection and update its status
const connection = this.connections.find((conn) => conn.server.name === serverName)
const config = connection?.server.config
if (config) {
vscode.window.showInformationMessage(`Restarting ${serverName} MCP server...`)
connection.server.status = "connecting"
connection.server.error = ""
await this.notifyWebviewOfServerChanges()
await delay(500) // artificial delay to show user that server is restarting
try {
await this.deleteConnection(serverName)
// Try to connect again using existing config
await this.connectToServer(serverName, JSON.parse(config))
vscode.window.showInformationMessage(`${serverName} MCP server connected`)
} catch (error) {
console.error(`Failed to restart connection for ${serverName}:`, error)
vscode.window.showErrorMessage(`Failed to connect to ${serverName} MCP server`)
}
}
await this.notifyWebviewOfServerChanges()
this.isConnecting = false
}
private async notifyWebviewOfServerChanges(): Promise<void> {
// servers should always be sorted in the order they are defined in the settings file
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const serverOrder = Object.keys(config.mcpServers || {})
await this.providerRef.deref()?.postMessageToWebview({
type: "mcpServers",
mcpServers: [...this.connections]
.sort((a, b) => {
const indexA = serverOrder.indexOf(a.server.name)
const indexB = serverOrder.indexOf(b.server.name)
return indexA - indexB
})
.map((connection) => connection.server),
})
}
// Using server
// Public methods for server management
public async toggleServerDisabled(serverName: string, disabled: boolean): Promise<void> {
let settingsPath: string
try {
settingsPath = await this.getMcpSettingsFilePath()
// Ensure the settings file exists and is accessible
try {
await fs.access(settingsPath)
} catch (error) {
console.error("Settings file not accessible:", error)
throw new Error("Settings file not accessible")
}
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
// Validate the config structure
if (!config || typeof config !== "object") {
throw new Error("Invalid config structure")
}
if (!config.mcpServers || typeof config.mcpServers !== "object") {
config.mcpServers = {}
}
if (config.mcpServers[serverName]) {
// Create a new server config object to ensure clean structure
const serverConfig = {
...config.mcpServers[serverName],
disabled,
}
// Ensure required fields exist
if (!serverConfig.autoApprove) {
serverConfig.autoApprove = []
}
config.mcpServers[serverName] = serverConfig
// Write the entire config back
const updatedConfig = {
mcpServers: config.mcpServers,
}
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (connection) {
try {
connection.server.disabled = disabled
// Only refresh capabilities if connected
if (connection.server.status === "connected") {
connection.server.tools = await this.fetchToolsList(serverName)
connection.server.resources = await this.fetchResourcesList(serverName)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName)
}
} catch (error) {
console.error(`Failed to refresh capabilities for ${serverName}:`, error)
}
}
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
console.error("Failed to update server disabled state:", error)
if (error instanceof Error) {
console.error("Error details:", error.message, error.stack)
}
vscode.window.showErrorMessage(
`Failed to update server state: ${error instanceof Error ? error.message : String(error)}`,
)
throw error
}
}
async readResource(serverName: string, uri: string): Promise<McpResourceResponse> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`No connection found for server: ${serverName}`)
}
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled`)
}
return await connection.client.request(
{
method: "resources/read",
params: {
uri,
},
},
ReadResourceResultSchema,
)
}
async callTool(serverName: string, toolName: string, toolArguments?: Record<string, unknown>): Promise<McpToolCallResponse> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
)
}
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
}
return await connection.client.request(
{
method: "tools/call",
params: {
name: toolName,
arguments: toolArguments,
},
},
CallToolResultSchema,
)
}
async toggleToolAutoApprove(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
// Initialize autoApprove if it doesn't exist
if (!config.mcpServers[serverName].autoApprove) {
config.mcpServers[serverName].autoApprove = []
}
const autoApprove = config.mcpServers[serverName].autoApprove
const toolIndex = autoApprove.indexOf(toolName)
if (shouldAllow && toolIndex === -1) {
// Add tool to autoApprove list
autoApprove.push(toolName)
} else if (!shouldAllow && toolIndex !== -1) {
// Remove tool from autoApprove list
autoApprove.splice(toolIndex, 1)
}
// Write updated config back to file
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
// Update the tools list to reflect the change
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (connection) {
connection.server.tools = await this.fetchToolsList(serverName)
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
console.error("Failed to update autoApprove settings:", error)
vscode.window.showErrorMessage("Failed to update autoApprove settings")
throw error // Re-throw to ensure the error is properly handled
}
}
async dispose(): Promise<void> {
this.removeAllFileWatchers()
for (const connection of this.connections) {
try {
await this.deleteConnection(connection.server.name)
} catch (error) {
console.error(`Failed to close connection for ${connection.server.name}:`, error)
}
}
dispose() {
// Clean up connections
this.connections = []
if (this.settingsWatcher) {
this.settingsWatcher.dispose()
this.isConnecting = false
this.mode = "off"
}
getMode(): "off" | "limited" | "full" {
return this.mode
}
getMcpServersPath(): Promise<string> {
return Promise.resolve("/Users/ocasta/Documents/Cline/MCP")
}
async callTool(serverName: string, toolName: string, args: any): Promise<any> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`Server ${serverName} not found`)
}
this.disposables.forEach((d) => d.dispose())
return connection.callTool(toolName, args)
}
async readResource(serverName: string, uri: string): Promise<any> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`Server ${serverName} not found`)
}
return connection.readResource(uri)
}
getConnections(): McpConnection[] {
return this.connections
}
isConnected(serverName: string): boolean {
return this.connections.some((conn) => conn.server.name === serverName)
}
}
+122 -162
View File
@@ -1,37 +1,39 @@
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello'
import { GitCommit } from "../utils/git"
import { ApiConfiguration, ModelInfo } from "./api"
import { AutoApprovalSettings } from "./AutoApprovalSettings"
import { BrowserSettings } from "./BrowserSettings"
import { ChatSettings } from "./ChatSettings"
import { ApiConfiguration } from "./api"
import { HistoryItem } from "./HistoryItem"
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
import { GitCommit } from "../utils/git"
// webview will hold state
export interface ExtensionMessage {
type:
| "action"
| "state"
| "selectedImages"
| "ollamaModels"
| "lmStudioModels"
| "theme"
| "workspaceUpdated"
| "invoke"
| "partialMessage"
| "openRouterModels"
| "openAiModels"
| "mcpServers"
| "relinquishControl"
| "vsCodeLmModels"
| "requestVsCodeLmModels"
| "emailSubscribed"
| "mcpMarketplaceCatalog"
| "mcpDownloadDetails"
| "commitSearchResults"
text?: string
action?:
export type Platform = "win32" | "darwin" | "linux"
export interface ExtensionState {
version: string
apiConfiguration: ApiConfiguration
customInstructions?: string
uriScheme: string
currentTaskItem?: HistoryItem
checkpointTrackerErrorMessage?: string
clineMessages: any[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
platform: Platform
autoApprovalSettings: any
browserSettings: any
chatSettings: any
isLoggedIn: boolean
userInfo?: any
commits?: GitCommit[]
workspaceFolders?: string[]
workspace?: string
}
export interface WorkspaceUpdateMessage {
type: "workspaceUpdated"
workspace: string
workspaceFolders: string[]
}
export interface ActionMessage {
type: "action"
action:
| "chatButtonClicked"
| "mcpButtonClicked"
| "settingsButtonClicked"
@@ -39,157 +41,115 @@ export interface ExtensionMessage {
| "didBecomeVisible"
| "accountLoginClicked"
| "accountLogoutClicked"
invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
state?: ExtensionState
images?: string[]
ollamaModels?: string[]
lmStudioModels?: string[]
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
filePaths?: string[]
partialMessage?: ClineMessage
openRouterModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
mcpMarketplaceCatalog?: McpMarketplaceCatalog
error?: string
mcpDownloadDetails?: McpDownloadResponse
commits?: GitCommit[]
}
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"
export const DEFAULT_PLATFORM = "unknown"
export interface ExtensionState {
version: string
apiConfiguration?: ApiConfiguration
customInstructions?: string
uriScheme?: string
currentTaskItem?: HistoryItem
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
chatSettings: ChatSettings
isLoggedIn: boolean
platform: Platform
userInfo?: {
displayName: string | null
email: string | null
photoURL: string | null
}
export interface StateMessage {
type: "state"
state: ExtensionState
}
export interface ClineMessage {
ts: number
type: "ask" | "say"
ask?: ClineAsk
say?: ClineSay
export interface ThemeMessage {
type: "theme"
text: string
}
export interface InvokeMessage {
type: "invoke"
invoke: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
text?: string
reasoning?: string
images?: string[]
partial?: boolean
lastCheckpointHash?: string
isCheckpointCheckedOut?: boolean
conversationHistoryIndex?: number
conversationHistoryDeletedRange?: [number, number] // for when conversation history is truncated for API requests
}
export type ClineAsk =
| "followup"
| "plan_mode_response"
| "command"
| "command_output"
| "completion_result"
| "tool"
| "api_req_failed"
| "resume_task"
| "resume_completed_task"
| "mistake_limit_reached"
| "auto_approval_max_req_reached"
| "browser_action_launch"
| "use_mcp_server"
export type ClineSay =
| "task"
| "error"
| "api_req_started"
| "api_req_finished"
| "text"
| "reasoning"
| "completion_result"
| "user_feedback"
| "user_feedback_diff"
| "api_req_retried"
| "command"
| "command_output"
| "tool"
| "shell_integration_warning"
| "browser_action_launch"
| "browser_action"
| "browser_action_result"
| "mcp_server_request_started"
| "mcp_server_response"
| "use_mcp_server"
| "diff_error"
| "deleted_api_reqs"
| "clineignore_error"
| "checkpoint_created"
export interface ClineSayTool {
tool:
| "editedExistingFile"
| "newFileCreated"
| "readFile"
| "listFilesTopLevel"
| "listFilesRecursive"
| "listCodeDefinitionNames"
| "searchFiles"
path?: string
diff?: string
content?: string
regex?: string
filePattern?: string
export interface SelectedImagesMessage {
type: "selectedImages"
images: string[]
}
// must keep in sync with system prompt
export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
export type BrowserAction = (typeof browserActions)[number]
export interface CommitSearchResultsMessage {
type: "commitSearchResults"
commits: GitCommit[]
}
export interface ClineSayBrowserAction {
action: BrowserAction
export interface BrowserAction {
type: "launch" | "click" | "type" | "scroll_down" | "scroll_up" | "close"
url?: string
coordinate?: string
text?: string
}
export type BrowserActionResult = {
export interface BrowserActionResult {
success: boolean
error?: string
screenshot?: string
logs?: string
currentUrl?: string
currentMousePosition?: string
logs?: string[]
}
export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
export interface ClineApiReqInfo {
id: string
model: string
inputTokens: number
outputTokens: number
cacheWrites: number
cacheReads: number
cost: number
}
export type ClineApiReqCancelReason = "user" | "error" | "timeout"
export interface ClineAsk {
text: string
type: "text" | "tool" | "browser" | "mcp"
requires_approval: boolean
}
export interface ClineAskUseMcpServer {
serverName: string
type: "use_mcp_tool" | "access_mcp_resource"
toolName?: string
arguments?: string
uri?: string
text: string
type: "mcp"
requires_approval: boolean
server_name: string
tool_name: string
}
export interface ClineApiReqInfo {
request?: string
tokensIn?: number
tokensOut?: number
cacheWrites?: number
cacheReads?: number
cost?: number
cancelReason?: ClineApiReqCancelReason
streamingFailedMessage?: string
export interface ClineSay {
text: string
type: "text"
}
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
export interface ClineSayTool {
text: string
type: "tool"
tool: string
args: string[]
}
export const COMPLETION_RESULT_CHANGES_FLAG = "HAS_CHANGES"
export interface ClineSayBrowserAction {
text: string
type: "browser"
action: BrowserAction
}
export type ClineMessage = ClineSay | ClineSayTool | ClineSayBrowserAction | ClineAsk | ClineAskUseMcpServer
export const COMPLETION_RESULT_CHANGES_FLAG = "COMPLETION_RESULT_CHANGES"
export interface PartialMessage {
type: "partialMessage"
text: string
}
export interface RelinquishControl {
type: "relinquishControl"
}
export type ExtensionMessage =
| WorkspaceUpdateMessage
| ActionMessage
| StateMessage
| ThemeMessage
| InvokeMessage
| SelectedImagesMessage
| CommitSearchResultsMessage
| PartialMessage
| RelinquishControl
+77 -63
View File
@@ -1,72 +1,86 @@
import { ApiConfiguration } from "./api"
import { AutoApprovalSettings } from "./AutoApprovalSettings"
import { BrowserSettings } from "./BrowserSettings"
import { ChatSettings } from "./ChatSettings"
import { ChatContent } from "./ChatContent"
export type WebviewMessageType =
| "webviewDidLaunch"
| "newTask"
| "apiConfiguration"
| "customInstructions"
| "autoApprovalSettings"
| "browserSettings"
| "chatSettings"
| "askResponse"
| "clearTask"
| "didShowAnnouncement"
| "selectImages"
| "exportCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "resetState"
| "openImage"
| "openFile"
| "openMention"
| "checkpointDiff"
| "checkpointRestore"
| "taskCompletionViewChanges"
| "cancelTask"
| "getLatestState"
| "accountLoginClicked"
| "accountLogoutClicked"
| "searchCommits"
| "openExtensionSettings"
export interface WebviewMessage {
type:
| "apiConfiguration"
| "customInstructions"
| "webviewDidLaunch"
| "newTask"
| "askResponse"
| "clearTask"
| "didShowAnnouncement"
| "selectImages"
| "exportCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "resetState"
| "requestOllamaModels"
| "requestLmStudioModels"
| "openImage"
| "openFile"
| "openMention"
| "cancelTask"
| "refreshOpenRouterModels"
| "refreshOpenAiModels"
| "openMcpSettings"
| "restartMcpServer"
| "autoApprovalSettings"
| "browserSettings"
| "chatSettings"
| "checkpointDiff"
| "checkpointRestore"
| "taskCompletionViewChanges"
| "openExtensionSettings"
| "requestVsCodeLmModels"
| "toggleToolAutoApprove"
| "toggleMcpServer"
| "getLatestState"
| "accountLoginClicked"
| "accountLogoutClicked"
| "subscribeEmail"
| "fetchMcpMarketplace"
| "downloadMcp"
| "silentlyRefreshMcpMarketplace"
| "searchCommits"
// | "relaunchChromeDebugMode"
type: WebviewMessageType
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
apiConfiguration?: ApiConfiguration
images?: string[]
bool?: boolean
apiConfiguration?: ApiConfiguration
autoApprovalSettings?: any
browserSettings?: any
chatSettings?: any
chatContent?: {
message?: string
images?: string[]
}
askResponse?: ClineAskResponse
number?: number
autoApprovalSettings?: AutoApprovalSettings
browserSettings?: BrowserSettings
chatSettings?: ChatSettings
chatContent?: ChatContent
mcpId?: string
// For toggleToolAutoApprove
serverName?: string
toolName?: string
autoApprove?: boolean
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
export interface ClineAskResponse {
text: string
images?: string[]
}
export type ClineCheckpointRestore = "task" | "workspace" | "taskAndWorkspace"
export interface ClineCheckpointRestore {
checkpointNumber: number
restoreMode: "task" | "workspace" | "taskAndWorkspace"
}
export interface ChatSettings {
mode: "act" | "plan"
autoScroll: boolean
showTimestamps: boolean
showCheckpoints: boolean
showDiffs: boolean
showLineNumbers: boolean
showGutter: boolean
showMinimap: boolean
showIndentGuides: boolean
showInvisibles: boolean
wordWrap: boolean
theme: string
fontSize: number
lineHeight: number
fontFamily: string
tabSize: number
useSoftTabs: boolean
useSpaces: boolean
trimTrailingWhitespace: boolean
insertFinalNewline: boolean
trimFinalNewlines: boolean
}
export interface AutoApprovalSettings {
enabled: boolean
tools: string[]
}
+303 -721
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
import { ApiProvider, ModelInfo } from "../shared/api"
import * as vscode from "vscode"
export type SecretKey =
| "apiKey"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
| "awsSessionToken"
| "openAiApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
| "requestyApiKey"
| "togetherApiKey"
| "qwenApiKey"
| "mistralApiKey"
| "liteLlmApiKey"
| "authToken"
| "authNonce"
export type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "awsRegion"
| "awsUseCrossRegionInference"
| "awsProfile"
| "awsUseProfile"
| "vertexProjectId"
| "vertexRegion"
| "lastShownAnnouncementId"
| "customInstructions"
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
| "openAiModelInfo"
| "ollamaModelId"
| "ollamaBaseUrl"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
| "autoApprovalSettings"
| "browserSettings"
| "chatSettings"
| "vsCodeLmModelSelector"
| "userInfo"
| "previousModeApiProvider"
| "previousModeModelId"
| "previousModeModelInfo"
| "liteLlmBaseUrl"
| "liteLlmModelId"
| "qwenApiLine"
| "requestyModelId"
| "togetherModelId"
| "mcpMarketplaceCatalog"
+12 -1
View File
@@ -7,6 +7,9 @@ describe("Cost Utilities", () => {
describe("calculateApiCost", () => {
it("should calculate basic input/output costs", () => {
const modelInfo: ModelInfo = {
id: "test-model",
name: "Test Model",
provider: "test",
supportsPromptCache: false,
inputPrice: 3.0, // $3 per million tokens
outputPrice: 15.0, // $15 per million tokens
@@ -21,6 +24,9 @@ describe("Cost Utilities", () => {
it("should handle missing prices", () => {
const modelInfo: ModelInfo = {
id: "test-model",
name: "Test Model",
provider: "test",
supportsPromptCache: true,
// No prices specified
}
@@ -31,10 +37,12 @@ describe("Cost Utilities", () => {
it("should use real model configuration (Claude 3.5 Sonnet)", () => {
const modelInfo: ModelInfo = {
id: "claude-3.5-sonnet",
name: "Claude 3.5 Sonnet",
provider: "anthropic",
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
@@ -53,6 +61,9 @@ describe("Cost Utilities", () => {
it("should handle zero token counts", () => {
const modelInfo: ModelInfo = {
id: "test-model",
name: "Test Model",
provider: "test",
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,