Просмотр исходного кода

Move & clean up model fetchers

Chris Estreich 10 месяцев назад
Родитель
Сommit
159621cea6

+ 33 - 1
src/api/providers/glama.ts

@@ -1,10 +1,12 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import axios from "axios"
 import OpenAI from "openai"
-import { ApiHandler, SingleCompletionHandler } from "../"
+
 import { ApiHandlerOptions, ModelInfo, glamaDefaultModelId, glamaDefaultModelInfo } from "../../shared/api"
+import { parseApiPrice } from "../../utils/cost"
 import { convertToOpenAiMessages } from "../transform/openai-format"
 import { ApiStream } from "../transform/stream"
+import { ApiHandler, SingleCompletionHandler } from "../"
 
 const GLAMA_DEFAULT_TEMPERATURE = 0
 
@@ -190,3 +192,33 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
 		}
 	}
 }
+
+export async function getGlamaModels() {
+	const models: Record<string, ModelInfo> = {}
+
+	try {
+		const response = await axios.get("https://glama.ai/api/gateway/v1/models")
+		const rawModels = response.data
+
+		for (const rawModel of rawModels) {
+			const modelInfo: ModelInfo = {
+				maxTokens: rawModel.maxTokensOutput,
+				contextWindow: rawModel.maxTokensInput,
+				supportsImages: rawModel.capabilities?.includes("input:image"),
+				supportsComputerUse: rawModel.capabilities?.includes("computer_use"),
+				supportsPromptCache: rawModel.capabilities?.includes("caching"),
+				inputPrice: parseApiPrice(rawModel.pricePerToken?.input),
+				outputPrice: parseApiPrice(rawModel.pricePerToken?.output),
+				description: undefined,
+				cacheWritesPrice: parseApiPrice(rawModel.pricePerToken?.cacheWrite),
+				cacheReadsPrice: parseApiPrice(rawModel.pricePerToken?.cacheRead),
+			}
+
+			models[rawModel.id] = modelInfo
+		}
+	} catch (error) {
+		console.error(`Error fetching Glama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
+	}
+
+	return models
+}

+ 16 - 0
src/api/providers/lmstudio.ts

@@ -1,5 +1,7 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import OpenAI from "openai"
+import axios from "axios"
+
 import { ApiHandler, SingleCompletionHandler } from "../"
 import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
 import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -72,3 +74,17 @@ export class LmStudioHandler implements ApiHandler, SingleCompletionHandler {
 		}
 	}
 }
+
+export async function getLmStudioModels(baseUrl = "http://localhost:1234") {
+	try {
+		if (!URL.canParse(baseUrl)) {
+			return []
+		}
+
+		const response = await axios.get(`${baseUrl}/v1/models`)
+		const modelsArray = response.data?.data?.map((model: any) => model.id) || []
+		return [...new Set<string>(modelsArray)]
+	} catch (error) {
+		return []
+	}
+}

+ 16 - 0
src/api/providers/ollama.ts

@@ -1,5 +1,7 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import OpenAI from "openai"
+import axios from "axios"
+
 import { ApiHandler, SingleCompletionHandler } from "../"
 import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
 import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -88,3 +90,17 @@ export class OllamaHandler implements ApiHandler, SingleCompletionHandler {
 		}
 	}
 }
+
+export async function getOllamaModels(baseUrl = "http://localhost:11434") {
+	try {
+		if (!URL.canParse(baseUrl)) {
+			return []
+		}
+
+		const response = await axios.get(`${baseUrl}/api/tags`)
+		const modelsArray = response.data?.models?.map((model: any) => model.name) || []
+		return [...new Set<string>(modelsArray)]
+	} catch (error) {
+		return []
+	}
+}

+ 25 - 0
src/api/providers/openai.ts

@@ -1,5 +1,6 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import OpenAI, { AzureOpenAI } from "openai"
+import axios from "axios"
 
 import {
 	ApiHandlerOptions,
@@ -166,3 +167,27 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler {
 		}
 	}
 }
+
+export async function getOpenAiModels(baseUrl?: string, apiKey?: string) {
+	try {
+		if (!baseUrl) {
+			return []
+		}
+
+		if (!URL.canParse(baseUrl)) {
+			return []
+		}
+
+		const config: Record<string, any> = {}
+
+		if (apiKey) {
+			config["headers"] = { Authorization: `Bearer ${apiKey}` }
+		}
+
+		const response = await axios.get(`${baseUrl}/models`, config)
+		const modelsArray = response.data?.data?.map((model: any) => model.id) || []
+		return [...new Set<string>(modelsArray)]
+	} catch (error) {
+		return []
+	}
+}

+ 79 - 7
src/api/providers/openrouter.ts

@@ -1,29 +1,29 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import axios from "axios"
 import OpenAI from "openai"
-import { ApiHandler } from "../"
+import delay from "delay"
+
 import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
+import { parseApiPrice } from "../../utils/cost"
 import { convertToOpenAiMessages } from "../transform/openai-format"
 import { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream"
-import delay from "delay"
+import { convertToR1Format } from "../transform/r1-format"
 import { DEEP_SEEK_DEFAULT_TEMPERATURE } from "./openai"
+import { ApiHandler, SingleCompletionHandler } from ".."
 
 const OPENROUTER_DEFAULT_TEMPERATURE = 0
 
-// Add custom interface for OpenRouter params
+// Add custom interface for OpenRouter params.
 type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & {
 	transforms?: string[]
 	include_reasoning?: boolean
 }
 
-// Add custom interface for OpenRouter usage chunk
+// Add custom interface for OpenRouter usage chunk.
 interface OpenRouterApiStreamUsageChunk extends ApiStreamUsageChunk {
 	fullResponseText: string
 }
 
-import { SingleCompletionHandler } from ".."
-import { convertToR1Format } from "../transform/r1-format"
-
 export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
 	private options: ApiHandlerOptions
 	private client: OpenAI
@@ -251,3 +251,75 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
 		}
 	}
 }
+
+export async function getOpenRouterModels() {
+	const models: Record<string, ModelInfo> = {}
+
+	try {
+		const response = await axios.get("https://openrouter.ai/api/v1/models")
+		const rawModels = response.data.data
+
+		for (const rawModel of rawModels) {
+			const modelInfo: ModelInfo = {
+				maxTokens: rawModel.top_provider?.max_completion_tokens,
+				contextWindow: rawModel.context_length,
+				supportsImages: rawModel.architecture?.modality?.includes("image"),
+				supportsPromptCache: false,
+				inputPrice: parseApiPrice(rawModel.pricing?.prompt),
+				outputPrice: parseApiPrice(rawModel.pricing?.completion),
+				description: rawModel.description,
+			}
+
+			switch (rawModel.id) {
+				case "anthropic/claude-3.7-sonnet":
+				case "anthropic/claude-3.7-sonnet:beta":
+				case "anthropic/claude-3.5-sonnet":
+				case "anthropic/claude-3.5-sonnet:beta":
+					// NOTE: This needs to be synced with api.ts/openrouter default model info.
+					modelInfo.supportsComputerUse = true
+					modelInfo.supportsPromptCache = true
+					modelInfo.cacheWritesPrice = 3.75
+					modelInfo.cacheReadsPrice = 0.3
+					break
+				case "anthropic/claude-3.5-sonnet-20240620":
+				case "anthropic/claude-3.5-sonnet-20240620:beta":
+					modelInfo.supportsPromptCache = true
+					modelInfo.cacheWritesPrice = 3.75
+					modelInfo.cacheReadsPrice = 0.3
+					break
+				case "anthropic/claude-3-5-haiku":
+				case "anthropic/claude-3-5-haiku:beta":
+				case "anthropic/claude-3-5-haiku-20241022":
+				case "anthropic/claude-3-5-haiku-20241022:beta":
+				case "anthropic/claude-3.5-haiku":
+				case "anthropic/claude-3.5-haiku:beta":
+				case "anthropic/claude-3.5-haiku-20241022":
+				case "anthropic/claude-3.5-haiku-20241022:beta":
+					modelInfo.supportsPromptCache = true
+					modelInfo.cacheWritesPrice = 1.25
+					modelInfo.cacheReadsPrice = 0.1
+					break
+				case "anthropic/claude-3-opus":
+				case "anthropic/claude-3-opus:beta":
+					modelInfo.supportsPromptCache = true
+					modelInfo.cacheWritesPrice = 18.75
+					modelInfo.cacheReadsPrice = 1.5
+					break
+				case "anthropic/claude-3-haiku":
+				case "anthropic/claude-3-haiku:beta":
+					modelInfo.supportsPromptCache = true
+					modelInfo.cacheWritesPrice = 0.3
+					modelInfo.cacheReadsPrice = 0.03
+					break
+			}
+
+			models[rawModel.id] = modelInfo
+		}
+	} catch (error) {
+		console.error(
+			`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
+		)
+	}
+
+	return models
+}

+ 42 - 2
src/api/providers/requesty.ts

@@ -1,6 +1,9 @@
-import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
+import axios from "axios"
+
 import { ModelInfo, requestyModelInfoSaneDefaults, requestyDefaultModelId } from "../../shared/api"
-import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
+import { parseApiPrice } from "../../utils/cost"
+import { ApiStreamUsageChunk } from "../transform/stream"
+import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
 
 export class RequestyHandler extends OpenAiHandler {
 	constructor(options: OpenAiHandlerOptions) {
@@ -38,3 +41,40 @@ export class RequestyHandler extends OpenAiHandler {
 		}
 	}
 }
+
+export async function getRequestyModels({ apiKey }: { apiKey?: string }) {
+	const models: Record<string, ModelInfo> = {}
+
+	if (!apiKey) {
+		return models
+	}
+
+	try {
+		const config: Record<string, any> = {}
+		config["headers"] = { Authorization: `Bearer ${apiKey}` }
+
+		const response = await axios.get("https://router.requesty.ai/v1/models", config)
+		const rawModels = response.data.data
+
+		for (const rawModel of rawModels) {
+			const modelInfo: ModelInfo = {
+				maxTokens: rawModel.max_output_tokens,
+				contextWindow: rawModel.context_window,
+				supportsImages: rawModel.support_image,
+				supportsComputerUse: rawModel.support_computer_use,
+				supportsPromptCache: rawModel.supports_caching,
+				inputPrice: parseApiPrice(rawModel.input_price),
+				outputPrice: parseApiPrice(rawModel.output_price),
+				description: rawModel.description,
+				cacheWritesPrice: parseApiPrice(rawModel.caching_price),
+				cacheReadsPrice: parseApiPrice(rawModel.cached_price),
+			}
+
+			models[rawModel.id] = modelInfo
+		}
+	} catch (error) {
+		console.error(`Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
+	}
+
+	return models
+}

+ 33 - 1
src/api/providers/unbound.ts

@@ -1,9 +1,11 @@
 import { Anthropic } from "@anthropic-ai/sdk"
+import axios from "axios"
 import OpenAI from "openai"
-import { ApiHandler, SingleCompletionHandler } from "../"
+
 import { ApiHandlerOptions, ModelInfo, unboundDefaultModelId, unboundDefaultModelInfo } from "../../shared/api"
 import { convertToOpenAiMessages } from "../transform/openai-format"
 import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
+import { ApiHandler, SingleCompletionHandler } from "../"
 
 interface UnboundUsage extends OpenAI.CompletionUsage {
 	cache_creation_input_tokens?: number
@@ -163,3 +165,33 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
 		}
 	}
 }
+
+export async function getUnboundModels() {
+	const models: Record<string, ModelInfo> = {}
+
+	try {
+		const response = await axios.get("https://api.getunbound.ai/models")
+
+		if (response.data) {
+			const rawModels: Record<string, any> = response.data
+
+			for (const [modelId, model] of Object.entries(rawModels)) {
+				models[modelId] = {
+					maxTokens: model?.maxTokens ? parseInt(model.maxTokens) : undefined,
+					contextWindow: model?.contextWindow ? parseInt(model.contextWindow) : 0,
+					supportsImages: model?.supportsImages ?? false,
+					supportsPromptCache: model?.supportsPromptCaching ?? false,
+					supportsComputerUse: model?.supportsComputerUse ?? false,
+					inputPrice: model?.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined,
+					outputPrice: model?.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined,
+					cacheWritesPrice: model?.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined,
+					cacheReadsPrice: model?.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined,
+				}
+			}
+		}
+	} catch (error) {
+		console.error(`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
+	}
+
+	return models
+}

+ 13 - 0
src/api/providers/vscode-lm.ts

@@ -1,5 +1,6 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import * as vscode from "vscode"
+
 import { ApiHandler, SingleCompletionHandler } from "../"
 import { calculateApiCost } from "../../utils/cost"
 import { ApiStream } from "../transform/stream"
@@ -545,3 +546,15 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
 		}
 	}
 }
+
+export async function getVsCodeLmModels() {
+	try {
+		const models = await vscode.lm.selectChatModels({})
+		return models || []
+	} catch (error) {
+		console.error(
+			`Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
+		)
+		return []
+	}
+}

+ 3 - 3
src/core/Cline.ts

@@ -47,6 +47,8 @@ import {
 import { getApiMetrics } from "../shared/getApiMetrics"
 import { HistoryItem } from "../shared/HistoryItem"
 import { ClineAskResponse } from "../shared/WebviewMessage"
+import { GlobalFileNames } from "../shared/globalFileNames"
+import { defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
 import { calculateApiCost } from "../utils/cost"
 import { fileExistsAtPath } from "../utils/fs"
 import { arePathsEqual, getReadablePath } from "../utils/path"
@@ -54,12 +56,10 @@ import { parseMentions } from "./mentions"
 import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
 import { formatResponse } from "./prompts/responses"
 import { SYSTEM_PROMPT } from "./prompts/system"
-import { modes, defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
 import { truncateConversationIfNeeded } from "./sliding-window"
-import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
+import { ClineProvider } from "./webview/ClineProvider"
 import { detectCodeOmission } from "../integrations/editor/detect-omission"
 import { BrowserSession } from "../services/browser/BrowserSession"
-import { OpenRouterHandler } from "../api/providers/openrouter"
 import { McpHub } from "../services/mcp/McpHub"
 import crypto from "crypto"
 import { insertGroups } from "./diff/insert-groups"

+ 14 - 3
src/core/config/__tests__/CustomModesManager.test.ts

@@ -1,3 +1,5 @@
+// npx jest src/core/config/__tests__/CustomModesManager.test.ts
+
 import * as vscode from "vscode"
 import * as path from "path"
 import * as fs from "fs/promises"
@@ -15,9 +17,10 @@ describe("CustomModesManager", () => {
 	let mockOnUpdate: jest.Mock
 	let mockWorkspaceFolders: { uri: { fsPath: string } }[]
 
-	const mockStoragePath = "/mock/settings"
+	// Use path.sep to ensure correct path separators for the current platform
+	const mockStoragePath = `${path.sep}mock${path.sep}settings`
 	const mockSettingsPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
-	const mockRoomodes = "/mock/workspace/.roomodes"
+	const mockRoomodes = `${path.sep}mock${path.sep}workspace${path.sep}.roomodes`
 
 	beforeEach(() => {
 		mockOnUpdate = jest.fn()
@@ -243,7 +246,15 @@ describe("CustomModesManager", () => {
 			await manager.updateCustomMode("project-mode", projectMode)
 
 			// Verify .roomodes was created with the project mode
-			expect(fs.writeFile).toHaveBeenCalledWith(mockRoomodes, expect.stringContaining("project-mode"), "utf-8")
+			expect(fs.writeFile).toHaveBeenCalledWith(
+				expect.any(String), // Don't check exact path as it may have different separators on different platforms
+				expect.stringContaining("project-mode"),
+				"utf-8",
+			)
+
+			// Verify the path is correct regardless of separators
+			const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
+			expect(path.normalize(writeCall[0])).toBe(path.normalize(mockRoomodes))
 
 			// Verify the content written to .roomodes
 			expect(roomodesContent).toEqual({

+ 172 - 550
src/core/webview/ClineProvider.ts

@@ -8,138 +8,51 @@ import * as path from "path"
 import * as vscode from "vscode"
 import simpleGit from "simple-git"
 
-import { buildApiHandler } from "../../api"
+import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
+import { findLast } from "../../shared/array"
+import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
+import { GlobalFileNames } from "../../shared/globalFileNames"
+import type { SecretKey, GlobalStateKey } from "../../shared/globalState"
+import { HistoryItem } from "../../shared/HistoryItem"
+import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
+import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
+import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
+import { checkExistKey } from "../../shared/checkExistApiConfig"
+import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
 import { downloadTask } from "../../integrations/misc/export-markdown"
 import { openFile, openImage } from "../../integrations/misc/open-file"
 import { selectImages } from "../../integrations/misc/process-images"
 import { getTheme } from "../../integrations/theme/getTheme"
-import { getDiffStrategy } from "../diff/DiffStrategy"
 import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
 import { McpHub } from "../../services/mcp/McpHub"
-import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
-import { findLast } from "../../shared/array"
-import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
-import { HistoryItem } from "../../shared/HistoryItem"
-import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
-import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
-import { SYSTEM_PROMPT } from "../prompts/system"
+import { McpServerManager } from "../../services/mcp/McpServerManager"
 import { fileExistsAtPath } from "../../utils/fs"
-import { Cline } from "../Cline"
-import { openMention } from "../mentions"
-import { getNonce } from "./getNonce"
-import { getUri } from "./getUri"
 import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
-import { checkExistKey } from "../../shared/checkExistApiConfig"
 import { singleCompletionHandler } from "../../utils/single-completion-handler"
 import { searchCommits } from "../../utils/git"
+import { getDiffStrategy } from "../diff/DiffStrategy"
+import { SYSTEM_PROMPT } from "../prompts/system"
 import { ConfigManager } from "../config/ConfigManager"
 import { CustomModesManager } from "../config/CustomModesManager"
-import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
-import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
-
+import { buildApiHandler } from "../../api"
+import { getOpenRouterModels } from "../../api/providers/openrouter"
+import { getGlamaModels } from "../../api/providers/glama"
+import { getUnboundModels } from "../../api/providers/unbound"
+import { getRequestyModels } from "../../api/providers/requesty"
+import { getOpenAiModels } from "../../api/providers/openai"
+import { getOllamaModels } from "../../api/providers/ollama"
+import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
+import { getLmStudioModels } from "../../api/providers/lmstudio"
 import { ACTION_NAMES } from "../CodeActionProvider"
-import { McpServerManager } from "../../services/mcp/McpServerManager"
+import { Cline } from "../Cline"
+import { openMention } from "../mentions"
+import { getNonce } from "./getNonce"
+import { getUri } from "./getUri"
 
-/*
-https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
-
-https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
-*/
-
-type SecretKey =
-	| "apiKey"
-	| "glamaApiKey"
-	| "openRouterApiKey"
-	| "awsAccessKey"
-	| "awsSecretKey"
-	| "awsSessionToken"
-	| "openAiApiKey"
-	| "geminiApiKey"
-	| "openAiNativeApiKey"
-	| "deepSeekApiKey"
-	| "mistralApiKey"
-	| "unboundApiKey"
-	| "requestyApiKey"
-type GlobalStateKey =
-	| "apiProvider"
-	| "apiModelId"
-	| "glamaModelId"
-	| "glamaModelInfo"
-	| "awsRegion"
-	| "awsUseCrossRegionInference"
-	| "awsProfile"
-	| "awsUseProfile"
-	| "vertexProjectId"
-	| "vertexRegion"
-	| "lastShownAnnouncementId"
-	| "customInstructions"
-	| "alwaysAllowReadOnly"
-	| "alwaysAllowWrite"
-	| "alwaysAllowExecute"
-	| "alwaysAllowBrowser"
-	| "alwaysAllowMcp"
-	| "alwaysAllowModeSwitch"
-	| "taskHistory"
-	| "openAiBaseUrl"
-	| "openAiModelId"
-	| "openAiCustomModelInfo"
-	| "openAiUseAzure"
-	| "ollamaModelId"
-	| "ollamaBaseUrl"
-	| "lmStudioModelId"
-	| "lmStudioBaseUrl"
-	| "anthropicBaseUrl"
-	| "anthropicThinking"
-	| "azureApiVersion"
-	| "openAiStreamingEnabled"
-	| "openRouterModelId"
-	| "openRouterModelInfo"
-	| "openRouterBaseUrl"
-	| "openRouterUseMiddleOutTransform"
-	| "allowedCommands"
-	| "soundEnabled"
-	| "soundVolume"
-	| "diffEnabled"
-	| "checkpointsEnabled"
-	| "browserViewportSize"
-	| "screenshotQuality"
-	| "fuzzyMatchThreshold"
-	| "preferredLanguage" // Language setting for Cline's communication
-	| "writeDelayMs"
-	| "terminalOutputLineLimit"
-	| "mcpEnabled"
-	| "enableMcpServerCreation"
-	| "alwaysApproveResubmit"
-	| "requestDelaySeconds"
-	| "rateLimitSeconds"
-	| "currentApiConfigName"
-	| "listApiConfigMeta"
-	| "vsCodeLmModelSelector"
-	| "mode"
-	| "modeApiConfigs"
-	| "customModePrompts"
-	| "customSupportPrompts"
-	| "enhancementApiConfigId"
-	| "experiments" // Map of experiment IDs to their enabled state
-	| "autoApprovalEnabled"
-	| "customModes" // Array of custom modes
-	| "unboundModelId"
-	| "requestyModelId"
-	| "requestyModelInfo"
-	| "unboundModelInfo"
-	| "modelTemperature"
-	| "mistralCodestralUrl"
-	| "maxOpenTabsContext"
-
-export const GlobalFileNames = {
-	apiConversationHistory: "api_conversation_history.json",
-	uiMessages: "ui_messages.json",
-	glamaModels: "glama_models.json",
-	openRouterModels: "openrouter_models.json",
-	requestyModels: "requesty_models.json",
-	mcpSettings: "cline_mcp_settings.json",
-	unboundModels: "unbound_models.json",
-}
+/**
+ * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
+ * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
+ */
 
 export class ClineProvider implements vscode.WebviewViewProvider {
 	public static readonly sideBarId = "roo-cline.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
@@ -619,15 +532,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 						this.postStateToWebview()
 						this.workspaceTracker?.initializeFilePaths() // don't await
+
 						getTheme().then((theme) =>
 							this.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }),
 						)
-						// post last cached models in case the call to endpoint fails
-						this.readOpenRouterModels().then((openRouterModels) => {
-							if (openRouterModels) {
-								this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
-							}
-						})
 
 						// If MCP Hub is already initialized, update the webview with current server list
 						if (this.mcpHub) {
@@ -637,13 +545,37 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 							})
 						}
 
-						// gui relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
-						// we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
-						// (see normalizeApiConfiguration > openrouter)
-						this.refreshOpenRouterModels().then(async (openRouterModels) => {
+						const cacheDir = await this.ensureCacheDirectoryExists()
+
+						// Post last cached models in case the call to endpoint fails.
+						this.readModelsFromCache(GlobalFileNames.openRouterModels).then((openRouterModels) => {
 							if (openRouterModels) {
-								// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
+								this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
+							}
+						})
+
+						// GUI relies on model info to be up-to-date to provide
+						// the most accurate pricing, so we need to fetch the
+						// latest details on launch.
+						// We do this for all users since many users switch
+						// between api providers and if they were to switch back
+						// to OpenRouter it would be showing outdated model info
+						// if we hadn't retrieved the latest at this point
+						// (see normalizeApiConfiguration > openrouter).
+						getOpenRouterModels().then(async (openRouterModels) => {
+							if (Object.keys(openRouterModels).length > 0) {
+								await fs.writeFile(
+									path.join(cacheDir, GlobalFileNames.openRouterModels),
+									JSON.stringify(openRouterModels),
+								)
+								await this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
+
+								// Update model info in state (this needs to be
+								// done here since we don't want to update state
+								// while settings is open, and we may refresh
+								// models there).
 								const { apiConfiguration } = await this.getState()
+
 								if (apiConfiguration.openRouterModelId) {
 									await this.updateGlobalState(
 										"openRouterModelInfo",
@@ -653,15 +585,23 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 								}
 							}
 						})
-						this.readGlamaModels().then((glamaModels) => {
+
+						this.readModelsFromCache(GlobalFileNames.glamaModels).then((glamaModels) => {
 							if (glamaModels) {
 								this.postMessageToWebview({ type: "glamaModels", glamaModels })
 							}
 						})
-						this.refreshGlamaModels().then(async (glamaModels) => {
-							if (glamaModels) {
-								// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
+
+						getGlamaModels().then(async (glamaModels) => {
+							if (Object.keys(glamaModels).length > 0) {
+								await fs.writeFile(
+									path.join(cacheDir, GlobalFileNames.glamaModels),
+									JSON.stringify(glamaModels),
+								)
+								await this.postMessageToWebview({ type: "glamaModels", glamaModels })
+
 								const { apiConfiguration } = await this.getState()
+
 								if (apiConfiguration.glamaModelId) {
 									await this.updateGlobalState(
 										"glamaModelInfo",
@@ -672,14 +612,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 							}
 						})
 
-						this.readUnboundModels().then((unboundModels) => {
+						this.readModelsFromCache(GlobalFileNames.unboundModels).then((unboundModels) => {
 							if (unboundModels) {
 								this.postMessageToWebview({ type: "unboundModels", unboundModels })
 							}
 						})
-						this.refreshUnboundModels().then(async (unboundModels) => {
-							if (unboundModels) {
+
+						getUnboundModels().then(async (unboundModels) => {
+							if (Object.keys(unboundModels).length > 0) {
+								await fs.writeFile(
+									path.join(cacheDir, GlobalFileNames.unboundModels),
+									JSON.stringify(unboundModels),
+								)
+								await this.postMessageToWebview({ type: "unboundModels", unboundModels })
+
 								const { apiConfiguration } = await this.getState()
+
 								if (apiConfiguration?.unboundModelId) {
 									await this.updateGlobalState(
 										"unboundModelInfo",
@@ -690,15 +638,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 							}
 						})
 
-						this.readRequestyModels().then((requestyModels) => {
+						this.readModelsFromCache(GlobalFileNames.requestyModels).then((requestyModels) => {
 							if (requestyModels) {
 								this.postMessageToWebview({ type: "requestyModels", requestyModels })
 							}
 						})
-						this.refreshRequestyModels().then(async (requestyModels) => {
-							if (requestyModels) {
-								// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
+
+						const requestyApiKey = await this.getSecret("requestyApiKey")
+
+						getRequestyModels({ apiKey: requestyApiKey }).then(async (requestyModels) => {
+							if (Object.keys(requestyModels).length > 0) {
+								await fs.writeFile(
+									path.join(cacheDir, GlobalFileNames.requestyModels),
+									JSON.stringify(requestyModels),
+								)
+								await this.postMessageToWebview({ type: "requestyModels", requestyModels })
+
 								const { apiConfiguration } = await this.getState()
+
 								if (apiConfiguration.requestyModelId) {
 									await this.updateGlobalState(
 										"requestyModelInfo",
@@ -841,41 +798,84 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 					case "resetState":
 						await this.resetState()
 						break
-					case "requestOllamaModels":
-						const ollamaModels = await this.getOllamaModels(message.text)
-						this.postMessageToWebview({ type: "ollamaModels", ollamaModels })
-						break
-					case "requestLmStudioModels":
-						const lmStudioModels = await this.getLmStudioModels(message.text)
-						this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels })
-						break
-					case "requestVsCodeLmModels":
-						const vsCodeLmModels = await this.getVsCodeLmModels()
-						this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
+					case "refreshOpenRouterModels":
+						const openRouterModels = await getOpenRouterModels()
+
+						if (Object.keys(openRouterModels).length > 0) {
+							const cacheDir = await this.ensureCacheDirectoryExists()
+							await fs.writeFile(
+								path.join(cacheDir, GlobalFileNames.openRouterModels),
+								JSON.stringify(openRouterModels),
+							)
+							await this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
+						}
+
 						break
 					case "refreshGlamaModels":
-						await this.refreshGlamaModels()
+						const glamaModels = await getGlamaModels()
+
+						if (Object.keys(glamaModels).length > 0) {
+							const cacheDir = await this.ensureCacheDirectoryExists()
+							await fs.writeFile(
+								path.join(cacheDir, GlobalFileNames.glamaModels),
+								JSON.stringify(glamaModels),
+							)
+							await this.postMessageToWebview({ type: "glamaModels", glamaModels })
+						}
+
 						break
-					case "refreshOpenRouterModels":
-						await this.refreshOpenRouterModels()
+					case "refreshUnboundModels":
+						const unboundModels = await getUnboundModels()
+
+						if (Object.keys(unboundModels).length > 0) {
+							const cacheDir = await this.ensureCacheDirectoryExists()
+							await fs.writeFile(
+								path.join(cacheDir, GlobalFileNames.unboundModels),
+								JSON.stringify(unboundModels),
+							)
+							await this.postMessageToWebview({ type: "unboundModels", unboundModels })
+						}
+
+						break
+					case "refreshRequestyModels":
+						if (message?.values?.apiKey) {
+							const requestyModels = await getRequestyModels({ apiKey: message.values.apiKey })
+
+							if (Object.keys(requestyModels).length > 0) {
+								const cacheDir = await this.ensureCacheDirectoryExists()
+								await fs.writeFile(
+									path.join(cacheDir, GlobalFileNames.requestyModels),
+									JSON.stringify(requestyModels),
+								)
+								await this.postMessageToWebview({ type: "requestyModels", requestyModels })
+							}
+						}
+
 						break
 					case "refreshOpenAiModels":
 						if (message?.values?.baseUrl && message?.values?.apiKey) {
-							const openAiModels = await this.getOpenAiModels(
+							const openAiModels = await getOpenAiModels(
 								message?.values?.baseUrl,
 								message?.values?.apiKey,
 							)
 							this.postMessageToWebview({ type: "openAiModels", openAiModels })
 						}
+
 						break
-					case "refreshUnboundModels":
-						await this.refreshUnboundModels()
+					case "requestOllamaModels":
+						const ollamaModels = await getOllamaModels(message.text)
+						// TODO: Cache like we do for OpenRouter, etc?
+						this.postMessageToWebview({ type: "ollamaModels", ollamaModels })
 						break
-					case "refreshRequestyModels":
-						if (message?.values?.apiKey) {
-							const requestyModels = await this.refreshRequestyModels(message?.values?.apiKey)
-							this.postMessageToWebview({ type: "requestyModels", requestyModels: requestyModels })
-						}
+					case "requestLmStudioModels":
+						const lmStudioModels = await getLmStudioModels(message.text)
+						// TODO: Cache like we do for OpenRouter, etc?
+						this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels })
+						break
+					case "requestVsCodeLmModels":
+						const vsCodeLmModels = await getVsCodeLmModels()
+						// TODO: Cache like we do for OpenRouter, etc?
+						this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
 						break
 					case "openImage":
 						openImage(message.text!)
@@ -1789,157 +1789,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		return settingsDir
 	}
 
-	// Ollama
-
-	async getOllamaModels(baseUrl?: string) {
-		try {
-			if (!baseUrl) {
-				baseUrl = "http://localhost:11434"
-			}
-			if (!URL.canParse(baseUrl)) {
-				return []
-			}
-			const response = await axios.get(`${baseUrl}/api/tags`)
-			const modelsArray = response.data?.models?.map((model: any) => model.name) || []
-			const models = [...new Set<string>(modelsArray)]
-			return models
-		} catch (error) {
-			return []
-		}
-	}
-
-	// LM Studio
-
-	async getLmStudioModels(baseUrl?: string) {
-		try {
-			if (!baseUrl) {
-				baseUrl = "http://localhost:1234"
-			}
-			if (!URL.canParse(baseUrl)) {
-				return []
-			}
-			const response = await axios.get(`${baseUrl}/v1/models`)
-			const modelsArray = response.data?.data?.map((model: any) => model.id) || []
-			const models = [...new Set<string>(modelsArray)]
-			return models
-		} catch (error) {
-			return []
-		}
-	}
-
-	// VSCode LM API
-	private async getVsCodeLmModels() {
-		try {
-			const models = await vscode.lm.selectChatModels({})
-			return models || []
-		} catch (error) {
-			this.outputChannel.appendLine(
-				`Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
-			)
-			return []
-		}
+	private async ensureCacheDirectoryExists() {
+		const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
+		await fs.mkdir(cacheDir, { recursive: true })
+		return cacheDir
 	}
 
-	// OpenAi
-
-	async getOpenAiModels(baseUrl?: string, apiKey?: string) {
-		try {
-			if (!baseUrl) {
-				return []
-			}
-
-			if (!URL.canParse(baseUrl)) {
-				return []
-			}
-
-			const config: Record<string, any> = {}
-			if (apiKey) {
-				config["headers"] = { Authorization: `Bearer ${apiKey}` }
-			}
-
-			const response = await axios.get(`${baseUrl}/models`, config)
-			const modelsArray = response.data?.data?.map((model: any) => model.id) || []
-			const models = [...new Set<string>(modelsArray)]
-			return models
-		} catch (error) {
-			return []
-		}
-	}
+	private async readModelsFromCache(filename: string): Promise<Record<string, ModelInfo> | undefined> {
+		const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
+		const fileExists = await fileExistsAtPath(filePath)
 
-	// Requesty
-	async readRequestyModels(): Promise<Record<string, ModelInfo> | undefined> {
-		const requestyModelsFilePath = path.join(
-			await this.ensureCacheDirectoryExists(),
-			GlobalFileNames.requestyModels,
-		)
-		const fileExists = await fileExistsAtPath(requestyModelsFilePath)
 		if (fileExists) {
-			const fileContents = await fs.readFile(requestyModelsFilePath, "utf8")
+			const fileContents = await fs.readFile(filePath, "utf8")
 			return JSON.parse(fileContents)
 		}
-		return undefined
-	}
-
-	async refreshRequestyModels(apiKey?: string) {
-		const requestyModelsFilePath = path.join(
-			await this.ensureCacheDirectoryExists(),
-			GlobalFileNames.requestyModels,
-		)
-
-		const models: Record<string, ModelInfo> = {}
-		try {
-			const config: Record<string, any> = {}
-			if (!apiKey) {
-				apiKey = (await this.getSecret("requestyApiKey")) as string
-			}
-
-			if (!apiKey) {
-				this.outputChannel.appendLine("No Requesty API key found")
-				return models
-			}
-
-			if (apiKey) {
-				config["headers"] = { Authorization: `Bearer ${apiKey}` }
-			}
 
-			const response = await axios.get("https://router.requesty.ai/v1/models", config)
-
-			if (response.data) {
-				const rawModels = response.data.data
-				const parsePrice = (price: any) => {
-					if (price) {
-						return parseFloat(price) * 1_000_000
-					}
-					return undefined
-				}
-				for (const rawModel of rawModels) {
-					const modelInfo: ModelInfo = {
-						maxTokens: rawModel.max_output_tokens,
-						contextWindow: rawModel.context_window,
-						supportsImages: rawModel.support_image,
-						supportsComputerUse: rawModel.support_computer_use,
-						supportsPromptCache: rawModel.supports_caching,
-						inputPrice: parsePrice(rawModel.input_price),
-						outputPrice: parsePrice(rawModel.output_price),
-						description: rawModel.description,
-						cacheWritesPrice: parsePrice(rawModel.caching_price),
-						cacheReadsPrice: parsePrice(rawModel.cached_price),
-					}
-
-					models[rawModel.id] = modelInfo
-				}
-			} else {
-				this.outputChannel.appendLine("Invalid response from Requesty API")
-			}
-			await fs.writeFile(requestyModelsFilePath, JSON.stringify(models))
-		} catch (error) {
-			this.outputChannel.appendLine(
-				`Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
-			)
-		}
-
-		await this.postMessageToWebview({ type: "requestyModels", requestyModels: models })
-		return models
+		return undefined
 	}
 
 	// OpenRouter
@@ -1970,11 +1835,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
 	}
 
-	private async ensureCacheDirectoryExists(): Promise<string> {
-		const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
-		await fs.mkdir(cacheDir, { recursive: true })
-		return cacheDir
-	}
+	// Glama
 
 	async handleGlamaCallback(code: string) {
 		let apiKey: string
@@ -2005,225 +1866,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
 	}
 
-	private async readModelsFromCache(filename: string): Promise<Record<string, ModelInfo> | undefined> {
-		const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
-		const fileExists = await fileExistsAtPath(filePath)
-		if (fileExists) {
-			const fileContents = await fs.readFile(filePath, "utf8")
-			return JSON.parse(fileContents)
-		}
-		return undefined
-	}
-
-	async readGlamaModels(): Promise<Record<string, ModelInfo> | undefined> {
-		return this.readModelsFromCache(GlobalFileNames.glamaModels)
-	}
-
-	async refreshGlamaModels() {
-		const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
-
-		const models: Record<string, ModelInfo> = {}
-		try {
-			const response = await axios.get("https://glama.ai/api/gateway/v1/models")
-			/*
-				{
-					"added": "2024-12-24T15:12:49.324Z",
-					"capabilities": [
-						"adjustable_safety_settings",
-						"caching",
-						"code_execution",
-						"function_calling",
-						"json_mode",
-						"json_schema",
-						"system_instructions",
-						"tuning",
-						"input:audio",
-						"input:image",
-						"input:text",
-						"input:video",
-						"output:text"
-					],
-					"id": "google-vertex/gemini-1.5-flash-002",
-					"maxTokensInput": 1048576,
-					"maxTokensOutput": 8192,
-					"pricePerToken": {
-						"cacheRead": null,
-						"cacheWrite": null,
-						"input": "0.000000075",
-						"output": "0.0000003"
-					}
-				}
-			*/
-			if (response.data) {
-				const rawModels = response.data
-				const parsePrice = (price: any) => {
-					if (price) {
-						return parseFloat(price) * 1_000_000
-					}
-					return undefined
-				}
-				for (const rawModel of rawModels) {
-					const modelInfo: ModelInfo = {
-						maxTokens: rawModel.maxTokensOutput,
-						contextWindow: rawModel.maxTokensInput,
-						supportsImages: rawModel.capabilities?.includes("input:image"),
-						supportsComputerUse: rawModel.capabilities?.includes("computer_use"),
-						supportsPromptCache: rawModel.capabilities?.includes("caching"),
-						inputPrice: parsePrice(rawModel.pricePerToken?.input),
-						outputPrice: parsePrice(rawModel.pricePerToken?.output),
-						description: undefined,
-						cacheWritesPrice: parsePrice(rawModel.pricePerToken?.cacheWrite),
-						cacheReadsPrice: parsePrice(rawModel.pricePerToken?.cacheRead),
-					}
-
-					models[rawModel.id] = modelInfo
-				}
-			} else {
-				this.outputChannel.appendLine("Invalid response from Glama API")
-			}
-			await fs.writeFile(glamaModelsFilePath, JSON.stringify(models))
-		} catch (error) {
-			this.outputChannel.appendLine(
-				`Error fetching Glama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
-			)
-		}
-
-		await this.postMessageToWebview({ type: "glamaModels", glamaModels: models })
-		return models
-	}
-
-	async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
-		return this.readModelsFromCache(GlobalFileNames.openRouterModels)
-	}
-
-	async refreshOpenRouterModels() {
-		const openRouterModelsFilePath = path.join(
-			await this.ensureCacheDirectoryExists(),
-			GlobalFileNames.openRouterModels,
-		)
-
-		const models: Record<string, ModelInfo> = {}
-
-		try {
-			const response = await axios.get("https://openrouter.ai/api/v1/models")
-
-			if (response.data?.data) {
-				const rawModels = response.data.data
-				const parsePrice = (price: any) => {
-					if (price) {
-						return parseFloat(price) * 1_000_000
-					}
-					return undefined
-				}
-
-				for (const rawModel of rawModels) {
-					const modelInfo: ModelInfo = {
-						maxTokens: rawModel.top_provider?.max_completion_tokens,
-						contextWindow: rawModel.context_length,
-						supportsImages: rawModel.architecture?.modality?.includes("image"),
-						supportsPromptCache: false,
-						inputPrice: parsePrice(rawModel.pricing?.prompt),
-						outputPrice: parsePrice(rawModel.pricing?.completion),
-						description: rawModel.description,
-					}
-
-					switch (rawModel.id) {
-						case "anthropic/claude-3.7-sonnet":
-						case "anthropic/claude-3.7-sonnet:beta":
-						case "anthropic/claude-3.5-sonnet":
-						case "anthropic/claude-3.5-sonnet:beta":
-							// NOTE: this needs to be synced with api.ts/openrouter default model info.
-							modelInfo.supportsComputerUse = true
-							modelInfo.supportsPromptCache = true
-							modelInfo.cacheWritesPrice = 3.75
-							modelInfo.cacheReadsPrice = 0.3
-							break
-						case "anthropic/claude-3.5-sonnet-20240620":
-						case "anthropic/claude-3.5-sonnet-20240620:beta":
-							modelInfo.supportsPromptCache = true
-							modelInfo.cacheWritesPrice = 3.75
-							modelInfo.cacheReadsPrice = 0.3
-							break
-						case "anthropic/claude-3-5-haiku":
-						case "anthropic/claude-3-5-haiku:beta":
-						case "anthropic/claude-3-5-haiku-20241022":
-						case "anthropic/claude-3-5-haiku-20241022:beta":
-						case "anthropic/claude-3.5-haiku":
-						case "anthropic/claude-3.5-haiku:beta":
-						case "anthropic/claude-3.5-haiku-20241022":
-						case "anthropic/claude-3.5-haiku-20241022:beta":
-							modelInfo.supportsPromptCache = true
-							modelInfo.cacheWritesPrice = 1.25
-							modelInfo.cacheReadsPrice = 0.1
-							break
-						case "anthropic/claude-3-opus":
-						case "anthropic/claude-3-opus:beta":
-							modelInfo.supportsPromptCache = true
-							modelInfo.cacheWritesPrice = 18.75
-							modelInfo.cacheReadsPrice = 1.5
-							break
-						case "anthropic/claude-3-haiku":
-						case "anthropic/claude-3-haiku:beta":
-							modelInfo.supportsPromptCache = true
-							modelInfo.cacheWritesPrice = 0.3
-							modelInfo.cacheReadsPrice = 0.03
-							break
-					}
-
-					models[rawModel.id] = modelInfo
-				}
-			} else {
-				this.outputChannel.appendLine("Invalid response from OpenRouter API")
-			}
-			await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
-		} catch (error) {
-			this.outputChannel.appendLine(
-				`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
-			)
-		}
-
-		await this.postMessageToWebview({ type: "openRouterModels", openRouterModels: models })
-		return models
-	}
-
-	async readUnboundModels(): Promise<Record<string, ModelInfo> | undefined> {
-		return this.readModelsFromCache(GlobalFileNames.unboundModels)
-	}
-
-	async refreshUnboundModels() {
-		const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
-
-		const models: Record<string, ModelInfo> = {}
-		try {
-			const response = await axios.get("https://api.getunbound.ai/models")
-
-			if (response.data) {
-				const rawModels: Record<string, any> = response.data
-				for (const [modelId, model] of Object.entries(rawModels)) {
-					models[modelId] = {
-						maxTokens: model?.maxTokens ? parseInt(model.maxTokens) : undefined,
-						contextWindow: model?.contextWindow ? parseInt(model.contextWindow) : 0,
-						supportsImages: model?.supportsImages ?? false,
-						supportsPromptCache: model?.supportsPromptCaching ?? false,
-						supportsComputerUse: model?.supportsComputerUse ?? false,
-						inputPrice: model?.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined,
-						outputPrice: model?.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined,
-						cacheWritesPrice: model?.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined,
-						cacheReadsPrice: model?.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined,
-					}
-				}
-			}
-			await fs.writeFile(unboundModelsFilePath, JSON.stringify(models))
-		} catch (error) {
-			this.outputChannel.appendLine(
-				`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
-			)
-		}
-
-		await this.postMessageToWebview({ type: "unboundModels", unboundModels: models })
-		return models
-	}
-
 	// Task history
 
 	async getTaskWithId(id: string): Promise<{
@@ -2810,26 +2452,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		return await this.context.globalState.get(key)
 	}
 
-	// workspace
-
-	private async updateWorkspaceState(key: string, value: any) {
-		await this.context.workspaceState.update(key, value)
-	}
-
-	private async getWorkspaceState(key: string) {
-		return await this.context.workspaceState.get(key)
-	}
-
-	// private async clearState() {
-	// 	this.context.workspaceState.keys().forEach((key) => {
-	// 		this.context.workspaceState.update(key, undefined)
-	// 	})
-	// 	this.context.globalState.keys().forEach((key) => {
-	// 		this.context.globalState.update(key, undefined)
-	// 	})
-	// 	this.context.secrets.delete("apiKey")
-	// }
-
 	// secrets
 
 	public async storeSecret(key: SecretKey, value?: string) {

+ 3 - 1
src/services/mcp/McpHub.ts

@@ -14,7 +14,9 @@ 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 { ClineProvider } from "../../core/webview/ClineProvider"
+import { GlobalFileNames } from "../../shared/globalFileNames"
 import {
 	McpResource,
 	McpResourceResponse,

+ 9 - 0
src/shared/globalFileNames.ts

@@ -0,0 +1,9 @@
+export const GlobalFileNames = {
+	apiConversationHistory: "api_conversation_history.json",
+	uiMessages: "ui_messages.json",
+	glamaModels: "glama_models.json",
+	openRouterModels: "openrouter_models.json",
+	requestyModels: "requesty_models.json",
+	mcpSettings: "cline_mcp_settings.json",
+	unboundModels: "unbound_models.json",
+}

+ 85 - 0
src/shared/globalState.ts

@@ -0,0 +1,85 @@
+export type SecretKey =
+	| "apiKey"
+	| "glamaApiKey"
+	| "openRouterApiKey"
+	| "awsAccessKey"
+	| "awsSecretKey"
+	| "awsSessionToken"
+	| "openAiApiKey"
+	| "geminiApiKey"
+	| "openAiNativeApiKey"
+	| "deepSeekApiKey"
+	| "mistralApiKey"
+	| "unboundApiKey"
+	| "requestyApiKey"
+
+export type GlobalStateKey =
+	| "apiProvider"
+	| "apiModelId"
+	| "glamaModelId"
+	| "glamaModelInfo"
+	| "awsRegion"
+	| "awsUseCrossRegionInference"
+	| "awsProfile"
+	| "awsUseProfile"
+	| "vertexProjectId"
+	| "vertexRegion"
+	| "lastShownAnnouncementId"
+	| "customInstructions"
+	| "alwaysAllowReadOnly"
+	| "alwaysAllowWrite"
+	| "alwaysAllowExecute"
+	| "alwaysAllowBrowser"
+	| "alwaysAllowMcp"
+	| "alwaysAllowModeSwitch"
+	| "taskHistory"
+	| "openAiBaseUrl"
+	| "openAiModelId"
+	| "openAiCustomModelInfo"
+	| "openAiUseAzure"
+	| "ollamaModelId"
+	| "ollamaBaseUrl"
+	| "lmStudioModelId"
+	| "lmStudioBaseUrl"
+	| "anthropicBaseUrl"
+	| "anthropicThinking"
+	| "azureApiVersion"
+	| "openAiStreamingEnabled"
+	| "openRouterModelId"
+	| "openRouterModelInfo"
+	| "openRouterBaseUrl"
+	| "openRouterUseMiddleOutTransform"
+	| "allowedCommands"
+	| "soundEnabled"
+	| "soundVolume"
+	| "diffEnabled"
+	| "checkpointsEnabled"
+	| "browserViewportSize"
+	| "screenshotQuality"
+	| "fuzzyMatchThreshold"
+	| "preferredLanguage" // Language setting for Cline's communication
+	| "writeDelayMs"
+	| "terminalOutputLineLimit"
+	| "mcpEnabled"
+	| "enableMcpServerCreation"
+	| "alwaysApproveResubmit"
+	| "requestDelaySeconds"
+	| "rateLimitSeconds"
+	| "currentApiConfigName"
+	| "listApiConfigMeta"
+	| "vsCodeLmModelSelector"
+	| "mode"
+	| "modeApiConfigs"
+	| "customModePrompts"
+	| "customSupportPrompts"
+	| "enhancementApiConfigId"
+	| "experiments" // Map of experiment IDs to their enabled state
+	| "autoApprovalEnabled"
+	| "customModes" // Array of custom modes
+	| "unboundModelId"
+	| "requestyModelId"
+	| "requestyModelInfo"
+	| "unboundModelInfo"
+	| "modelTemperature"
+	| "mistralCodestralUrl"
+	| "maxOpenTabsContext"

+ 20 - 14
src/utils/__tests__/path.test.ts

@@ -1,6 +1,9 @@
-import { arePathsEqual, getReadablePath } from "../path"
-import * as path from "path"
+// npx jest src/utils/__tests__/path.test.ts
+
 import os from "os"
+import * as path from "path"
+
+import { arePathsEqual, getReadablePath } from "../path"
 
 describe("Path Utilities", () => {
 	const originalPlatform = process.platform
@@ -92,22 +95,24 @@ describe("Path Utilities", () => {
 	describe("getReadablePath", () => {
 		const homeDir = os.homedir()
 		const desktop = path.join(homeDir, "Desktop")
+		const cwd = process.platform === "win32" ? "C:\\Users\\test\\project" : "/Users/test/project"
 
 		it("should return basename when path equals cwd", () => {
-			const cwd = "/Users/test/project"
 			expect(getReadablePath(cwd, cwd)).toBe("project")
 		})
 
 		it("should return relative path when inside cwd", () => {
-			const cwd = "/Users/test/project"
-			const filePath = "/Users/test/project/src/file.txt"
+			const filePath =
+				process.platform === "win32"
+					? "C:\\Users\\test\\project\\src\\file.txt"
+					: "/Users/test/project/src/file.txt"
 			expect(getReadablePath(cwd, filePath)).toBe("src/file.txt")
 		})
 
 		it("should return absolute path when outside cwd", () => {
-			const cwd = "/Users/test/project"
-			const filePath = "/Users/test/other/file.txt"
-			expect(getReadablePath(cwd, filePath)).toBe("/Users/test/other/file.txt")
+			const filePath =
+				process.platform === "win32" ? "C:\\Users\\test\\other\\file.txt" : "/Users/test/other/file.txt"
+			expect(getReadablePath(cwd, filePath)).toBe(filePath.toPosix())
 		})
 
 		it("should handle Desktop as cwd", () => {
@@ -116,19 +121,20 @@ describe("Path Utilities", () => {
 		})
 
 		it("should handle undefined relative path", () => {
-			const cwd = "/Users/test/project"
 			expect(getReadablePath(cwd)).toBe("project")
 		})
 
 		it("should handle parent directory traversal", () => {
-			const cwd = "/Users/test/project"
-			const filePath = "../../other/file.txt"
-			expect(getReadablePath(cwd, filePath)).toBe("/Users/other/file.txt")
+			const filePath =
+				process.platform === "win32" ? "C:\\Users\\test\\other\\file.txt" : "/Users/test/other/file.txt"
+			expect(getReadablePath(cwd, filePath)).toBe(filePath.toPosix())
 		})
 
 		it("should normalize paths with redundant segments", () => {
-			const cwd = "/Users/test/project"
-			const filePath = "/Users/test/project/./src/../src/file.txt"
+			const filePath =
+				process.platform === "win32"
+					? "C:\\Users\\test\\project\\src\\file.txt"
+					: "/Users/test/project/./src/../src/file.txt"
 			expect(getReadablePath(cwd, filePath)).toBe("src/file.txt")
 		})
 	})

+ 2 - 0
src/utils/cost.ts

@@ -22,3 +22,5 @@ export function calculateApiCost(
 	const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
 	return totalCost
 }
+
+export const parseApiPrice = (price: any) => (price ? parseFloat(price) * 1_000_000 : undefined)

+ 1 - 1
webview-ui/src/components/chat/TaskHeader.tsx

@@ -351,7 +351,7 @@ const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
 		<Button variant="ghost" size="sm" onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
 			<span className="codicon codicon-cloud-download" />
 		</Button>
-		{item?.size && (
+		{!!item?.size && item.size > 0 && (
 			<Button
 				variant="ghost"
 				size="sm"