Explorar el Código

Merge pull request #1202 from RooVetGit/cte/fix-model-picker

Fix model picker
Chris Estreich hace 10 meses
padre
commit
bdc96a0681

+ 5 - 0
.changeset/real-ties-destroy.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Fix model picker

+ 25 - 11
src/api/providers/requesty.ts

@@ -42,26 +42,33 @@ export class RequestyHandler extends OpenAiHandler {
 	}
 }
 
-export async function getRequestyModels({ apiKey }: { apiKey?: string }) {
+export async function getRequestyModels() {
 	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 response = await axios.get("https://router.requesty.ai/v1/models")
 		const rawModels = response.data.data
 
 		for (const rawModel of rawModels) {
+			// {
+			// 	id: "anthropic/claude-3-5-sonnet-20240620",
+			// 	object: "model",
+			// 	created: 1740552655,
+			// 	owned_by: "system",
+			// 	input_price: 0.0000028,
+			// 	caching_price: 0.00000375,
+			// 	cached_price: 3e-7,
+			// 	output_price: 0.000015,
+			// 	max_output_tokens: 8192,
+			// 	context_window: 200000,
+			// 	supports_caching: true,
+			// 	description:
+			// 		"Anthropic's previous most intelligent model. High level of intelligence and capability. Excells in coding.",
+			// }
+
 			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),
@@ -72,8 +79,15 @@ export async function getRequestyModels({ apiKey }: { apiKey?: string }) {
 
 			switch (rawModel.id) {
 				case rawModel.id.startsWith("anthropic/claude-3-7-sonnet"):
+					modelInfo.supportsComputerUse = true
+					modelInfo.supportsImages = true
 					modelInfo.maxTokens = 16384
 					break
+				case rawModel.id.startsWith("anthropic/claude-3-5-sonnet-20241022"):
+					modelInfo.supportsComputerUse = true
+					modelInfo.supportsImages = true
+					modelInfo.maxTokens = 8192
+					break
 				case rawModel.id.startsWith("anthropic/"):
 					modelInfo.maxTokens = 8192
 					break

+ 9 - 13
src/core/webview/ClineProvider.ts

@@ -644,9 +644,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 							}
 						})
 
-						const requestyApiKey = await this.getSecret("requestyApiKey")
-
-						getRequestyModels({ apiKey: requestyApiKey }).then(async (requestyModels) => {
+						getRequestyModels().then(async (requestyModels) => {
 							if (Object.keys(requestyModels).length > 0) {
 								await fs.writeFile(
 									path.join(cacheDir, GlobalFileNames.requestyModels),
@@ -838,17 +836,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 						break
 					case "refreshRequestyModels":
-						if (message?.values?.apiKey) {
-							const requestyModels = await getRequestyModels({ apiKey: message.values.apiKey })
+						const requestyModels = await getRequestyModels()
 
-							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 })
-							}
+						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

+ 5 - 6
src/shared/ExtensionMessage.ts

@@ -27,10 +27,11 @@ export interface ExtensionMessage {
 		| "workspaceUpdated"
 		| "invoke"
 		| "partialMessage"
-		| "glamaModels"
 		| "openRouterModels"
-		| "openAiModels"
+		| "glamaModels"
+		| "unboundModels"
 		| "requestyModels"
+		| "openAiModels"
 		| "mcpServers"
 		| "enhancedPrompt"
 		| "commitSearchResults"
@@ -43,8 +44,6 @@ export interface ExtensionMessage {
 		| "autoApprovalEnabled"
 		| "updateCustomMode"
 		| "deleteCustomMode"
-		| "unboundModels"
-		| "refreshUnboundModels"
 		| "currentCheckpointUpdated"
 	text?: string
 	action?:
@@ -67,11 +66,11 @@ export interface ExtensionMessage {
 		path?: string
 	}>
 	partialMessage?: ClineMessage
+	openRouterModels?: Record<string, ModelInfo>
 	glamaModels?: Record<string, ModelInfo>
+	unboundModels?: Record<string, ModelInfo>
 	requestyModels?: Record<string, ModelInfo>
-	openRouterModels?: Record<string, ModelInfo>
 	openAiModels?: string[]
-	unboundModels?: Record<string, ModelInfo>
 	mcpServers?: McpServer[]
 	commits?: GitCommit[]
 	listApiConfig?: ApiConfigMeta[]

+ 2 - 3
src/shared/WebviewMessage.ts

@@ -40,11 +40,11 @@ export interface WebviewMessage {
 		| "openFile"
 		| "openMention"
 		| "cancelTask"
-		| "refreshGlamaModels"
 		| "refreshOpenRouterModels"
-		| "refreshOpenAiModels"
+		| "refreshGlamaModels"
 		| "refreshUnboundModels"
 		| "refreshRequestyModels"
+		| "refreshOpenAiModels"
 		| "alwaysAllowBrowser"
 		| "alwaysAllowMcp"
 		| "alwaysAllowModeSwitch"
@@ -71,7 +71,6 @@ export interface WebviewMessage {
 		| "mcpEnabled"
 		| "enableMcpServerCreation"
 		| "searchCommits"
-		| "refreshGlamaModels"
 		| "alwaysApproveResubmit"
 		| "requestDelaySeconds"
 		| "rateLimitSeconds"

+ 2 - 0
webview-ui/package-lock.json

@@ -3674,6 +3674,7 @@
 			"version": "1.1.6",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz",
 			"integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==",
+			"license": "MIT",
 			"dependencies": {
 				"@radix-ui/primitive": "1.1.1",
 				"@radix-ui/react-compose-refs": "1.1.1",
@@ -4719,6 +4720,7 @@
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
 			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
+			"license": "MIT",
 			"dependencies": {
 				"@radix-ui/react-compose-refs": "1.1.1"
 			},

+ 9 - 9
webview-ui/src/components/settings/ApiErrorMessage.tsx

@@ -4,13 +4,13 @@ interface ApiErrorMessageProps {
 	errorMessage: string | undefined
 	children?: React.ReactNode
 }
-const ApiErrorMessage = ({ errorMessage, children }: ApiErrorMessageProps) => {
-	return (
-		<div className="text-vscode-errorForeground text-sm">
-			<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
-			{errorMessage}
-			{children}
+
+export const ApiErrorMessage = ({ errorMessage, children }: ApiErrorMessageProps) => (
+	<div className="flex flex-col gap-2 text-vscode-errorForeground text-sm">
+		<div className="flex flex-row items-center gap-1">
+			<div className="codicon codicon-close" />
+			<div>{errorMessage}</div>
 		</div>
-	)
-}
-export default ApiErrorMessage
+		{children}
+	</div>
+)

+ 201 - 234
webview-ui/src/components/settings/ApiOptions.tsx

@@ -4,8 +4,6 @@ import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
 import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 import * as vscodemodels from "vscode"
 
-import { Slider } from "@/components/ui"
-
 import {
 	ApiConfiguration,
 	ModelInfo,
@@ -33,7 +31,6 @@ import {
 	unboundDefaultModelInfo,
 	requestyDefaultModelId,
 	requestyDefaultModelInfo,
-	THINKING_BUDGET,
 } from "../../../../src/shared/api"
 import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
 
@@ -44,7 +41,18 @@ import { DROPDOWN_Z_INDEX } from "./styles"
 import { ModelPicker } from "./ModelPicker"
 import { TemperatureControl } from "./TemperatureControl"
 import { validateApiConfiguration, validateModelId } from "@/utils/validate"
-import ApiErrorMessage from "./ApiErrorMessage"
+import { ApiErrorMessage } from "./ApiErrorMessage"
+import { ThinkingBudget } from "./ThinkingBudget"
+
+const modelsByProvider: Record<string, Record<string, ModelInfo>> = {
+	anthropic: anthropicModels,
+	bedrock: bedrockModels,
+	vertex: vertexModels,
+	gemini: geminiModels,
+	"openai-native": openAiNativeModels,
+	deepseek: deepSeekModels,
+	mistral: mistralModels,
+}
 
 interface ApiOptionsProps {
 	uriScheme: string | undefined
@@ -66,18 +74,23 @@ const ApiOptions = ({
 	const [ollamaModels, setOllamaModels] = useState<string[]>([])
 	const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
 	const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
+
 	const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
 		[openRouterDefaultModelId]: openRouterDefaultModelInfo,
 	})
+
 	const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
 		[glamaDefaultModelId]: glamaDefaultModelInfo,
 	})
+
 	const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
 		[unboundDefaultModelId]: unboundDefaultModelInfo,
 	})
+
 	const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
 		[requestyDefaultModelId]: requestyDefaultModelInfo,
 	})
+
 	const [openAiModels, setOpenAiModels] = useState<Record<string, ModelInfo> | null>(null)
 
 	const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
@@ -85,8 +98,6 @@ const ApiOptions = ({
 	const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
 	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
 
-	const anthropicThinkingBudget = apiConfiguration?.anthropicThinking ?? THINKING_BUDGET.default
-
 	const noTransform = <T,>(value: T) => value
 	const inputEventTransform = <E,>(event: E) => (event as { target: HTMLInputElement })?.target?.value as any
 	const dropdownEventTransform = <T,>(event: DropdownOption | string | undefined) =>
@@ -103,62 +114,87 @@ const ApiOptions = ({
 		[setApiConfigurationField],
 	)
 
-	const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
-		return normalizeApiConfiguration(apiConfiguration)
-	}, [apiConfiguration])
+	const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(
+		() => normalizeApiConfiguration(apiConfiguration),
+		[apiConfiguration],
+	)
 
-	// Pull ollama/lmstudio models
-	// Debounced model updates, only executed 250ms after the user stops typing
+	// Debounced refresh model updates, only executed 250ms after the user
+	// stops typing.
 	useDebounce(
 		() => {
-			if (selectedProvider === "ollama") {
-				vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl })
-			} else if (selectedProvider === "lmstudio") {
-				vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl })
-			} else if (selectedProvider === "vscode-lm") {
-				vscode.postMessage({ type: "requestVsCodeLmModels" })
-			} else if (selectedProvider === "openai") {
-				vscode.postMessage({
-					type: "refreshOpenAiModels",
-					values: {
-						baseUrl: apiConfiguration?.openAiBaseUrl,
-						apiKey: apiConfiguration?.openAiApiKey,
-					},
-				})
-			} else if (selectedProvider === "openrouter") {
-				vscode.postMessage({ type: "refreshOpenRouterModels", values: {} })
+			if (selectedProvider === "openrouter") {
+				vscode.postMessage({ type: "refreshOpenRouterModels" })
 			} else if (selectedProvider === "glama") {
-				vscode.postMessage({ type: "refreshGlamaModels", values: {} })
+				vscode.postMessage({ type: "refreshGlamaModels" })
+			} else if (selectedProvider === "unbound") {
+				vscode.postMessage({ type: "refreshUnboundModels" })
 			} else if (selectedProvider === "requesty") {
 				vscode.postMessage({
 					type: "refreshRequestyModels",
-					values: {
-						apiKey: apiConfiguration?.requestyApiKey,
-					},
+					values: { apiKey: apiConfiguration?.requestyApiKey },
+				})
+			} else if (selectedProvider === "openai") {
+				vscode.postMessage({
+					type: "refreshOpenAiModels",
+					values: { baseUrl: apiConfiguration?.openAiBaseUrl, apiKey: apiConfiguration?.openAiApiKey },
 				})
+			} else if (selectedProvider === "ollama") {
+				vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl })
+			} else if (selectedProvider === "lmstudio") {
+				vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl })
+			} else if (selectedProvider === "vscode-lm") {
+				vscode.postMessage({ type: "requestVsCodeLmModels" })
 			}
 		},
 		250,
 		[
 			selectedProvider,
-			apiConfiguration?.ollamaBaseUrl,
-			apiConfiguration?.lmStudioBaseUrl,
+			apiConfiguration?.requestyApiKey,
 			apiConfiguration?.openAiBaseUrl,
 			apiConfiguration?.openAiApiKey,
-			apiConfiguration?.requestyApiKey,
+			apiConfiguration?.ollamaBaseUrl,
+			apiConfiguration?.lmStudioBaseUrl,
 		],
 	)
 
 	useEffect(() => {
 		const apiValidationResult =
 			validateApiConfiguration(apiConfiguration) ||
-			validateModelId(apiConfiguration, glamaModels, openRouterModels, unboundModels)
+			validateModelId(apiConfiguration, glamaModels, openRouterModels, unboundModels, requestyModels)
+
 		setErrorMessage(apiValidationResult)
-	}, [apiConfiguration, glamaModels, openRouterModels, setErrorMessage, unboundModels])
+	}, [apiConfiguration, glamaModels, openRouterModels, setErrorMessage, unboundModels, requestyModels])
 
-	const handleMessage = useCallback((event: MessageEvent) => {
+	const onMessage = useCallback((event: MessageEvent) => {
 		const message: ExtensionMessage = event.data
+
 		switch (message.type) {
+			case "openRouterModels": {
+				const updatedModels = message.openRouterModels ?? {}
+				setOpenRouterModels({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, ...updatedModels })
+				break
+			}
+			case "glamaModels": {
+				const updatedModels = message.glamaModels ?? {}
+				setGlamaModels({ [glamaDefaultModelId]: glamaDefaultModelInfo, ...updatedModels })
+				break
+			}
+			case "unboundModels": {
+				const updatedModels = message.unboundModels ?? {}
+				setUnboundModels({ [unboundDefaultModelId]: unboundDefaultModelInfo, ...updatedModels })
+				break
+			}
+			case "requestyModels": {
+				const updatedModels = message.requestyModels ?? {}
+				setRequestyModels({ [requestyDefaultModelId]: requestyDefaultModelInfo, ...updatedModels })
+				break
+			}
+			case "openAiModels": {
+				const updatedModels = message.openAiModels ?? []
+				setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults])))
+				break
+			}
 			case "ollamaModels":
 				{
 					const newModels = message.ollamaModels ?? []
@@ -177,72 +213,30 @@ const ApiOptions = ({
 					setVsCodeLmModels(newModels)
 				}
 				break
-			case "glamaModels": {
-				const updatedModels = message.glamaModels ?? {}
-				setGlamaModels({
-					[glamaDefaultModelId]: glamaDefaultModelInfo, // in case the extension sent a model list without the default model
-					...updatedModels,
-				})
-				break
-			}
-			case "openRouterModels": {
-				const updatedModels = message.openRouterModels ?? {}
-				setOpenRouterModels({
-					[openRouterDefaultModelId]: openRouterDefaultModelInfo, // in case the extension sent a model list without the default model
-					...updatedModels,
-				})
-				break
-			}
-			case "openAiModels": {
-				const updatedModels = message.openAiModels ?? []
-				setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults])))
-				break
-			}
-			case "unboundModels": {
-				const updatedModels = message.unboundModels ?? {}
-				setUnboundModels(updatedModels)
-				break
-			}
-			case "requestyModels": {
-				const updatedModels = message.requestyModels ?? {}
-				setRequestyModels({
-					[requestyDefaultModelId]: requestyDefaultModelInfo, // in case the extension sent a model list without the default model
-					...updatedModels,
-				})
-				break
-			}
 		}
 	}, [])
 
-	useEvent("message", handleMessage)
-
-	const createDropdown = (models: Record<string, ModelInfo>) => {
-		const options: DropdownOption[] = [
-			{ value: "", label: "Select a model..." },
-			...Object.keys(models).map((modelId) => ({
-				value: modelId,
-				label: modelId,
-			})),
-		]
-
-		return (
-			<Dropdown
-				id="model-id"
-				value={selectedModelId}
-				onChange={(value) => {
-					setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value)
-				}}
-				style={{ width: "100%" }}
-				options={options}
-			/>
-		)
-	}
+	useEvent("message", onMessage)
+
+	const selectedProviderModelOptions: DropdownOption[] = useMemo(
+		() =>
+			modelsByProvider[selectedProvider]
+				? [
+						{ value: "", label: "Select a model..." },
+						...Object.keys(modelsByProvider[selectedProvider]).map((modelId) => ({
+							value: modelId,
+							label: modelId,
+						})),
+					]
+				: [],
+		[selectedProvider],
+	)
 
 	return (
 		<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
 			<div className="dropdown-container">
-				<label htmlFor="api-provider">
-					<span style={{ fontWeight: 500 }}>API Provider</span>
+				<label htmlFor="api-provider" className="font-medium">
+					API Provider
 				</label>
 				<Dropdown
 					id="api-provider"
@@ -269,6 +263,8 @@ const ApiOptions = ({
 				/>
 			</div>
 
+			{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
+
 			{selectedProvider === "anthropic" && (
 				<div>
 					<VSCodeTextField
@@ -277,7 +273,7 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("apiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>Anthropic API Key</span>
+						<span className="font-medium">Anthropic API Key</span>
 					</VSCodeTextField>
 
 					<Checkbox
@@ -328,7 +324,7 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("glamaApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>Glama API Key</span>
+						<span className="font-medium">Glama API Key</span>
 					</VSCodeTextField>
 					{!apiConfiguration?.glamaApiKey && (
 						<VSCodeButtonLink
@@ -357,7 +353,7 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("requestyApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>Requesty API Key</span>
+						<span className="font-medium">Requesty API Key</span>
 					</VSCodeTextField>
 					<p
 						style={{
@@ -378,7 +374,7 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("openAiNativeApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>OpenAI API Key</span>
+						<span className="font-medium">OpenAI API Key</span>
 					</VSCodeTextField>
 					<p
 						style={{
@@ -406,7 +402,7 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("mistralApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>Mistral API Key</span>
+						<span className="font-medium">Mistral API Key</span>
 					</VSCodeTextField>
 					<p
 						style={{
@@ -435,7 +431,7 @@ const ApiOptions = ({
 								type="url"
 								onInput={handleInputChange("mistralCodestralUrl")}
 								placeholder="Default: https://codestral.mistral.ai">
-								<span style={{ fontWeight: 500 }}>Codestral Base URL (Optional)</span>
+								<span className="font-medium">Codestral Base URL (Optional)</span>
 							</VSCodeTextField>
 							<p
 								style={{
@@ -458,7 +454,7 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("openRouterApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
+						<span className="font-medium">OpenRouter API Key</span>
 					</VSCodeTextField>
 					{!apiConfiguration?.openRouterApiKey && (
 						<p>
@@ -530,7 +526,7 @@ const ApiOptions = ({
 							style={{ width: "100%" }}
 							onInput={handleInputChange("awsProfile")}
 							placeholder="Enter profile name">
-							<span style={{ fontWeight: 500 }}>AWS Profile Name</span>
+							<span className="font-medium">AWS Profile Name</span>
 						</VSCodeTextField>
 					) : (
 						<>
@@ -541,7 +537,7 @@ const ApiOptions = ({
 								type="password"
 								onInput={handleInputChange("awsAccessKey")}
 								placeholder="Enter Access Key...">
-								<span style={{ fontWeight: 500 }}>AWS Access Key</span>
+								<span className="font-medium">AWS Access Key</span>
 							</VSCodeTextField>
 							<VSCodeTextField
 								value={apiConfiguration?.awsSecretKey || ""}
@@ -549,7 +545,7 @@ const ApiOptions = ({
 								type="password"
 								onInput={handleInputChange("awsSecretKey")}
 								placeholder="Enter Secret Key...">
-								<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
+								<span className="font-medium">AWS Secret Key</span>
 							</VSCodeTextField>
 							<VSCodeTextField
 								value={apiConfiguration?.awsSessionToken || ""}
@@ -557,13 +553,13 @@ const ApiOptions = ({
 								type="password"
 								onInput={handleInputChange("awsSessionToken")}
 								placeholder="Enter Session Token...">
-								<span style={{ fontWeight: 500 }}>AWS Session Token</span>
+								<span className="font-medium">AWS Session Token</span>
 							</VSCodeTextField>
 						</>
 					)}
 					<div className="dropdown-container">
 						<label htmlFor="aws-region-dropdown">
-							<span style={{ fontWeight: 500 }}>AWS Region</span>
+							<span className="font-medium">AWS Region</span>
 						</label>
 						<Dropdown
 							id="aws-region-dropdown"
@@ -615,11 +611,11 @@ const ApiOptions = ({
 						style={{ width: "100%" }}
 						onInput={handleInputChange("vertexProjectId")}
 						placeholder="Enter Project ID...">
-						<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
+						<span className="font-medium">Google Cloud Project ID</span>
 					</VSCodeTextField>
 					<div className="dropdown-container">
 						<label htmlFor="vertex-region-dropdown">
-							<span style={{ fontWeight: 500 }}>Google Cloud Region</span>
+							<span className="font-medium">Google Cloud Region</span>
 						</label>
 						<Dropdown
 							id="vertex-region-dropdown"
@@ -636,7 +632,6 @@ const ApiOptions = ({
 							]}
 						/>
 					</div>
-					{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
 					<p
 						style={{
 							fontSize: "12px",
@@ -668,7 +663,7 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("geminiApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>Gemini API Key</span>
+						<span className="font-medium">Gemini API Key</span>
 					</VSCodeTextField>
 					<p
 						style={{
@@ -696,7 +691,7 @@ const ApiOptions = ({
 						type="url"
 						onInput={handleInputChange("openAiBaseUrl")}
 						placeholder={"Enter base URL..."}>
-						<span style={{ fontWeight: 500 }}>Base URL</span>
+						<span className="font-medium">Base URL</span>
 					</VSCodeTextField>
 					<VSCodeTextField
 						value={apiConfiguration?.openAiApiKey || ""}
@@ -704,19 +699,18 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("openAiApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>API Key</span>
+						<span className="font-medium">API Key</span>
 					</VSCodeTextField>
 					<ModelPicker
 						apiConfiguration={apiConfiguration}
+						setApiConfigurationField={setApiConfigurationField}
+						defaultModelId="gpt-4o"
+						defaultModelInfo={openAiModelInfoSaneDefaults}
+						models={openAiModels}
 						modelIdKey="openAiModelId"
 						modelInfoKey="openAiCustomModelInfo"
 						serviceName="OpenAI"
 						serviceUrl="https://platform.openai.com"
-						recommendedModel="gpt-4-turbo-preview"
-						models={openAiModels}
-						setApiConfigurationField={setApiConfigurationField}
-						defaultModelInfo={openAiModelInfoSaneDefaults}
-						errorMessage={errorMessage}
 					/>
 					<div style={{ display: "flex", alignItems: "center" }}>
 						<Checkbox
@@ -749,12 +743,7 @@ const ApiOptions = ({
 							placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
 						/>
 					)}
-
-					<div
-						style={{
-							marginTop: 15,
-						}}
-					/>
+					<div className="mt-4" />
 					<Pane
 						title="Model Configuration"
 						open={false}
@@ -814,7 +803,7 @@ const ApiOptions = ({
 												}
 											})}
 											placeholder="e.g. 4096">
-											<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
+											<span className="font-medium">Max Output Tokens</span>
 										</VSCodeTextField>
 										<div
 											style={{
@@ -864,7 +853,7 @@ const ApiOptions = ({
 												}
 											})}
 											placeholder="e.g. 128000">
-											<span style={{ fontWeight: 500 }}>Context Window Size</span>
+											<span className="font-medium">Context Window Size</span>
 										</VSCodeTextField>
 										<div
 											style={{
@@ -902,7 +891,7 @@ const ApiOptions = ({
 														supportsImages: checked,
 													}
 												})}>
-												<span style={{ fontWeight: 500 }}>Image Support</span>
+												<span className="font-medium">Image Support</span>
 											</Checkbox>
 											<i
 												className="codicon codicon-info"
@@ -946,7 +935,7 @@ const ApiOptions = ({
 														supportsComputerUse: checked,
 													}
 												})}>
-												<span style={{ fontWeight: 500 }}>Computer Use</span>
+												<span className="font-medium">Computer Use</span>
 											</Checkbox>
 											<i
 												className="codicon codicon-info"
@@ -1011,7 +1000,7 @@ const ApiOptions = ({
 											})}
 											placeholder="e.g. 0.0001">
 											<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
-												<span style={{ fontWeight: 500 }}>Input Price</span>
+												<span className="font-medium">Input Price</span>
 												<i
 													className="codicon codicon-info"
 													title="Cost per million tokens in the input/prompt. This affects the cost of sending context and instructions to the model."
@@ -1056,7 +1045,7 @@ const ApiOptions = ({
 											})}
 											placeholder="e.g. 0.0002">
 											<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
-												<span style={{ fontWeight: 500 }}>Output Price</span>
+												<span className="font-medium">Output Price</span>
 												<i
 													className="codicon codicon-info"
 													title="Cost per million tokens in the model's response. This affects the cost of generated content and completions."
@@ -1091,17 +1080,15 @@ const ApiOptions = ({
 						type="url"
 						onInput={handleInputChange("lmStudioBaseUrl")}
 						placeholder={"Default: http://localhost:1234"}>
-						<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
+						<span className="font-medium">Base URL (optional)</span>
 					</VSCodeTextField>
 					<VSCodeTextField
 						value={apiConfiguration?.lmStudioModelId || ""}
 						style={{ width: "100%" }}
 						onInput={handleInputChange("lmStudioModelId")}
 						placeholder={"e.g. meta-llama-3.1-8b-instruct"}>
-						<span style={{ fontWeight: 500 }}>Model ID</span>
+						<span className="font-medium">Model ID</span>
 					</VSCodeTextField>
-					{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
-
 					{lmStudioModels.length > 0 && (
 						<VSCodeRadioGroup
 							value={
@@ -1139,7 +1126,7 @@ const ApiOptions = ({
 						</VSCodeLink>{" "}
 						feature to use it with this extension.{" "}
 						<span style={{ color: "var(--vscode-errorForeground)" }}>
-							(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
+							(<span className="font-medium">Note:</span> Roo Code uses complex prompts and works best
 							with Claude models. Less capable models may not work as expected.)
 						</span>
 					</p>
@@ -1154,7 +1141,7 @@ const ApiOptions = ({
 						type="password"
 						onInput={handleInputChange("deepSeekApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>DeepSeek API Key</span>
+						<span className="font-medium">DeepSeek API Key</span>
 					</VSCodeTextField>
 					<p
 						style={{
@@ -1178,7 +1165,7 @@ const ApiOptions = ({
 				<div>
 					<div className="dropdown-container">
 						<label htmlFor="vscode-lm-model">
-							<span style={{ fontWeight: 500 }}>Language Model</span>
+							<span className="font-medium">Language Model</span>
 						</label>
 						{vsCodeLmModels.length > 0 ? (
 							<Dropdown
@@ -1237,14 +1224,14 @@ const ApiOptions = ({
 						type="url"
 						onInput={handleInputChange("ollamaBaseUrl")}
 						placeholder={"Default: http://localhost:11434"}>
-						<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
+						<span className="font-medium">Base URL (optional)</span>
 					</VSCodeTextField>
 					<VSCodeTextField
 						value={apiConfiguration?.ollamaModelId || ""}
 						style={{ width: "100%" }}
 						onInput={handleInputChange("ollamaModelId")}
 						placeholder={"e.g. llama3.1"}>
-						<span style={{ fontWeight: 500 }}>Model ID</span>
+						<span className="font-medium">Model ID</span>
 					</VSCodeTextField>
 					{errorMessage && (
 						<div className="text-vscode-errorForeground text-sm">
@@ -1284,7 +1271,7 @@ const ApiOptions = ({
 							quickstart guide.
 						</VSCodeLink>
 						<span style={{ color: "var(--vscode-errorForeground)" }}>
-							(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
+							(<span className="font-medium">Note:</span> Roo Code uses complex prompts and works best
 							with Claude models. Less capable models may not work as expected.)
 						</span>
 					</p>
@@ -1299,7 +1286,7 @@ const ApiOptions = ({
 						type="password"
 						onChange={handleInputChange("unboundApiKey")}
 						placeholder="Enter API Key...">
-						<span style={{ fontWeight: 500 }}>Unbound API Key</span>
+						<span className="font-medium">Unbound API Key</span>
 					</VSCodeTextField>
 					{!apiConfiguration?.unboundApiKey && (
 						<VSCodeButtonLink
@@ -1317,25 +1304,27 @@ const ApiOptions = ({
 						}}>
 						This key is stored locally and only used to make API requests from this extension.
 					</p>
-					<ModelPicker
-						apiConfiguration={apiConfiguration}
-						defaultModelId={unboundDefaultModelId}
-						defaultModelInfo={unboundDefaultModelInfo}
-						models={unboundModels}
-						modelInfoKey="unboundModelInfo"
-						modelIdKey="unboundModelId"
-						serviceName="Unbound"
-						serviceUrl="https://api.getunbound.ai/models"
-						recommendedModel={unboundDefaultModelId}
-						setApiConfigurationField={setApiConfigurationField}
-						errorMessage={errorMessage}
-					/>
 				</div>
 			)}
 
+			{selectedProvider === "openrouter" && (
+				<ModelPicker
+					apiConfiguration={apiConfiguration}
+					setApiConfigurationField={setApiConfigurationField}
+					defaultModelId={openRouterDefaultModelId}
+					defaultModelInfo={openRouterDefaultModelInfo}
+					models={openRouterModels}
+					modelIdKey="openRouterModelId"
+					modelInfoKey="openRouterModelInfo"
+					serviceName="OpenRouter"
+					serviceUrl="https://openrouter.ai/models"
+				/>
+			)}
+
 			{selectedProvider === "glama" && (
 				<ModelPicker
-					apiConfiguration={apiConfiguration ?? {}}
+					apiConfiguration={apiConfiguration}
+					setApiConfigurationField={setApiConfigurationField}
 					defaultModelId={glamaDefaultModelId}
 					defaultModelInfo={glamaDefaultModelInfo}
 					models={glamaModels}
@@ -1343,27 +1332,23 @@ const ApiOptions = ({
 					modelIdKey="glamaModelId"
 					serviceName="Glama"
 					serviceUrl="https://glama.ai/models"
-					recommendedModel="anthropic/claude-3-7-sonnet"
-					setApiConfigurationField={setApiConfigurationField}
-					errorMessage={errorMessage}
 				/>
 			)}
 
-			{selectedProvider === "openrouter" && (
+			{selectedProvider === "unbound" && (
 				<ModelPicker
 					apiConfiguration={apiConfiguration}
+					defaultModelId={unboundDefaultModelId}
+					defaultModelInfo={unboundDefaultModelInfo}
+					models={unboundModels}
+					modelInfoKey="unboundModelInfo"
+					modelIdKey="unboundModelId"
+					serviceName="Unbound"
+					serviceUrl="https://api.getunbound.ai/models"
 					setApiConfigurationField={setApiConfigurationField}
-					defaultModelId={openRouterDefaultModelId}
-					defaultModelInfo={openRouterDefaultModelInfo}
-					models={openRouterModels}
-					modelIdKey="openRouterModelId"
-					modelInfoKey="openRouterModelInfo"
-					serviceName="OpenRouter"
-					serviceUrl="https://openrouter.ai/models"
-					recommendedModel="anthropic/claude-3.7-sonnet"
-					errorMessage={errorMessage}
 				/>
 			)}
+
 			{selectedProvider === "requesty" && (
 				<ModelPicker
 					apiConfiguration={apiConfiguration}
@@ -1375,58 +1360,37 @@ const ApiOptions = ({
 					modelInfoKey="requestyModelInfo"
 					serviceName="Requesty"
 					serviceUrl="https://requesty.ai"
-					recommendedModel="anthropic/claude-3-7-sonnet-latest"
-					errorMessage={errorMessage}
 				/>
 			)}
 
-			{selectedProvider !== "glama" &&
-				selectedProvider !== "openrouter" &&
-				selectedProvider !== "requesty" &&
-				selectedProvider !== "openai" &&
-				selectedProvider !== "ollama" &&
-				selectedProvider !== "lmstudio" &&
-				selectedProvider !== "unbound" && (
-					<>
-						<div className="dropdown-container">
-							<label htmlFor="model-id">
-								<span style={{ fontWeight: 500 }}>Model</span>
-							</label>
-							{selectedProvider === "anthropic" && createDropdown(anthropicModels)}
-							{selectedProvider === "bedrock" && createDropdown(bedrockModels)}
-							{selectedProvider === "vertex" && createDropdown(vertexModels)}
-							{selectedProvider === "gemini" && createDropdown(geminiModels)}
-							{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
-							{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
-							{selectedProvider === "mistral" && createDropdown(mistralModels)}
-						</div>
-						{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
-						<ModelInfoView
-							selectedModelId={selectedModelId}
-							modelInfo={selectedModelInfo}
-							isDescriptionExpanded={isDescriptionExpanded}
-							setIsDescriptionExpanded={setIsDescriptionExpanded}
-						/>
-					</>
-				)}
-
-			{selectedModelInfo && selectedModelInfo.thinking && (
-				<div className="flex flex-col gap-1 mt-2">
-					<div className="font-medium">Thinking Budget</div>
-					<div className="flex items-center gap-1">
-						<Slider
-							min={THINKING_BUDGET.min}
-							max={(selectedModelInfo.maxTokens ?? THINKING_BUDGET.default) - 1}
-							step={THINKING_BUDGET.step}
-							value={[anthropicThinkingBudget]}
-							onValueChange={(value) => setApiConfigurationField("anthropicThinking", value[0])}
+			{selectedProviderModelOptions.length > 0 && (
+				<>
+					<div className="dropdown-container">
+						<label htmlFor="model-id" className="font-medium">
+							Model
+						</label>
+						<Dropdown
+							id="model-id"
+							value={selectedModelId}
+							onChange={(value) => {
+								setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value)
+							}}
+							options={selectedProviderModelOptions}
+							className="w-full"
 						/>
-						<div className="w-12 text-sm text-center">{anthropicThinkingBudget}</div>
-					</div>
-					<div className="text-muted-foreground text-sm">
-						Number of tokens Claude is allowed to use for its internal reasoning process.
 					</div>
-				</div>
+					<ThinkingBudget
+						apiConfiguration={apiConfiguration}
+						setApiConfigurationField={setApiConfigurationField}
+						modelInfo={selectedModelInfo}
+					/>
+					<ModelInfoView
+						selectedModelId={selectedModelId}
+						modelInfo={selectedModelInfo}
+						isDescriptionExpanded={isDescriptionExpanded}
+						setIsDescriptionExpanded={setIsDescriptionExpanded}
+					/>
+				</>
 			)}
 
 			{!fromWelcomeView && (
@@ -1459,6 +1423,7 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
 	const getProviderData = (models: Record<string, ModelInfo>, defaultId: string) => {
 		let selectedModelId: string
 		let selectedModelInfo: ModelInfo
+
 		if (modelId && modelId in models) {
 			selectedModelId = modelId
 			selectedModelInfo = models[modelId]
@@ -1466,8 +1431,10 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
 			selectedModelId = defaultId
 			selectedModelInfo = models[defaultId]
 		}
+
 		return { selectedProvider: provider, selectedModelId, selectedModelInfo }
 	}
+
 	switch (provider) {
 		case "anthropic":
 			return getProviderData(anthropicModels, anthropicDefaultModelId)
@@ -1481,19 +1448,31 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
 			return getProviderData(deepSeekModels, deepSeekDefaultModelId)
 		case "openai-native":
 			return getProviderData(openAiNativeModels, openAiNativeDefaultModelId)
+		case "mistral":
+			return getProviderData(mistralModels, mistralDefaultModelId)
+		case "openrouter":
+			return {
+				selectedProvider: provider,
+				selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
+				selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo,
+			}
 		case "glama":
 			return {
 				selectedProvider: provider,
 				selectedModelId: apiConfiguration?.glamaModelId || glamaDefaultModelId,
 				selectedModelInfo: apiConfiguration?.glamaModelInfo || glamaDefaultModelInfo,
 			}
-		case "mistral":
-			return getProviderData(mistralModels, mistralDefaultModelId)
-		case "openrouter":
+		case "unbound":
 			return {
 				selectedProvider: provider,
-				selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
-				selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo,
+				selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
+				selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
+			}
+		case "requesty":
+			return {
+				selectedProvider: provider,
+				selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
+				selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
 			}
 		case "openai":
 			return {
@@ -1521,21 +1500,9 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
 					: "",
 				selectedModelInfo: {
 					...openAiModelInfoSaneDefaults,
-					supportsImages: false, // VSCode LM API currently doesn't support images
+					supportsImages: false, // VSCode LM API currently doesn't support images.
 				},
 			}
-		case "unbound":
-			return {
-				selectedProvider: provider,
-				selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
-				selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
-			}
-		case "requesty":
-			return {
-				selectedProvider: provider,
-				selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
-				selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
-			}
 		default:
 			return getProviderData(anthropicModels, anthropicDefaultModelId)
 	}

+ 35 - 56
webview-ui/src/components/settings/ModelPicker.tsx

@@ -1,11 +1,13 @@
+import { useMemo, useState, useCallback, useEffect, useRef } from "react"
 import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
-import { useMemo, useState, useCallback, useEffect } from "react"
+
+import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem } from "@/components/ui/combobox"
+
+import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
 
 import { normalizeApiConfiguration } from "./ApiOptions"
+import { ThinkingBudget } from "./ThinkingBudget"
 import { ModelInfoView } from "./ModelInfoView"
-import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
-import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem } from "../ui/combobox"
-import ApiErrorMessage from "./ApiErrorMessage"
 
 type ExtractType<T> = NonNullable<
 	{ [K in keyof ApiConfiguration]: Required<ApiConfiguration>[K] extends T ? K : never }[keyof ApiConfiguration]
@@ -14,24 +16,17 @@ type ExtractType<T> = NonNullable<
 type ModelIdKeys = NonNullable<
 	{ [K in keyof ApiConfiguration]: K extends `${string}ModelId` ? K : never }[keyof ApiConfiguration]
 >
-declare module "react" {
-	interface CSSProperties {
-		// Allow CSS variables
-		[key: `--${string}`]: string | number
-	}
-}
+
 interface ModelPickerProps {
-	defaultModelId?: string
+	defaultModelId: string
+	defaultModelInfo?: ModelInfo
 	models: Record<string, ModelInfo> | null
 	modelIdKey: ModelIdKeys
 	modelInfoKey: ExtractType<ModelInfo>
 	serviceName: string
 	serviceUrl: string
-	recommendedModel: string
 	apiConfiguration: ApiConfiguration
 	setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
-	defaultModelInfo?: ModelInfo
-	errorMessage?: string
 }
 
 export const ModelPicker = ({
@@ -41,13 +36,12 @@ export const ModelPicker = ({
 	modelInfoKey,
 	serviceName,
 	serviceUrl,
-	recommendedModel,
 	apiConfiguration,
 	setApiConfigurationField,
 	defaultModelInfo,
-	errorMessage,
 }: ModelPickerProps) => {
 	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
+	const isInitialized = useRef(false)
 
 	const modelIds = useMemo(() => Object.keys(models ?? {}).sort((a, b) => a.localeCompare(b)), [models])
 
@@ -55,6 +49,7 @@ export const ModelPicker = ({
 		() => normalizeApiConfiguration(apiConfiguration),
 		[apiConfiguration],
 	)
+
 	const onSelect = useCallback(
 		(modelId: string) => {
 			const modelInfo = models?.[modelId]
@@ -63,26 +58,23 @@ export const ModelPicker = ({
 		},
 		[modelIdKey, modelInfoKey, models, setApiConfigurationField, defaultModelInfo],
 	)
+
+	const inputValue = apiConfiguration[modelIdKey]
+
 	useEffect(() => {
-		if (apiConfiguration[modelIdKey] == null && defaultModelId) {
-			onSelect(defaultModelId)
+		if (!inputValue && !isInitialized.current) {
+			const initialValue = modelIds.includes(selectedModelId) ? selectedModelId : defaultModelId
+			setApiConfigurationField(modelIdKey, initialValue)
 		}
-	}, [apiConfiguration, defaultModelId, modelIdKey, onSelect])
+
+		isInitialized.current = true
+	}, [inputValue, modelIds, setApiConfigurationField, modelIdKey, selectedModelId, defaultModelId])
 
 	return (
 		<>
 			<div className="font-semibold">Model</div>
-			<Combobox
-				style={errorMessage ? { "--color-vscode-dropdown-border": "var(--color-vscode-errorForeground)" } : {}}
-				type="single"
-				inputValue={apiConfiguration[modelIdKey]}
-				onInputValueChange={onSelect}>
-				<ComboboxInput
-					className="border-vscode-errorForeground tefat"
-					placeholder="Search model..."
-					data-testid="model-input"
-					aria-errormessage={errorMessage}
-				/>
+			<Combobox type="single" inputValue={inputValue} onInputValueChange={onSelect}>
+				<ComboboxInput placeholder="Search model..." data-testid="model-input" />
 				<ComboboxContent>
 					<ComboboxEmpty>No model found.</ComboboxEmpty>
 					{modelIds.map((model) => (
@@ -92,31 +84,18 @@ export const ModelPicker = ({
 					))}
 				</ComboboxContent>
 			</Combobox>
-
-			{errorMessage ? (
-				<ApiErrorMessage errorMessage={errorMessage}>
-					<p
-						style={{
-							fontSize: "12px",
-							marginTop: 3,
-							color: "var(--vscode-descriptionForeground)",
-						}}>
-						<span style={{ color: "var(--vscode-errorForeground)" }}>
-							<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
-							with Claude models. Less capable models may not work as expected.
-						</span>
-					</p>
-				</ApiErrorMessage>
-			) : (
-				selectedModelId &&
-				selectedModelInfo && (
-					<ModelInfoView
-						selectedModelId={selectedModelId}
-						modelInfo={selectedModelInfo}
-						isDescriptionExpanded={isDescriptionExpanded}
-						setIsDescriptionExpanded={setIsDescriptionExpanded}
-					/>
-				)
+			<ThinkingBudget
+				apiConfiguration={apiConfiguration}
+				setApiConfigurationField={setApiConfigurationField}
+				modelInfo={selectedModelInfo}
+			/>
+			{selectedModelId && selectedModelInfo && selectedModelId === inputValue && (
+				<ModelInfoView
+					selectedModelId={selectedModelId}
+					modelInfo={selectedModelInfo}
+					isDescriptionExpanded={isDescriptionExpanded}
+					setIsDescriptionExpanded={setIsDescriptionExpanded}
+				/>
 			)}
 			<p>
 				The extension automatically fetches the latest list of models available on{" "}
@@ -124,7 +103,7 @@ export const ModelPicker = ({
 					{serviceName}.
 				</VSCodeLink>
 				If you're unsure which model to choose, Roo Code works best with{" "}
-				<VSCodeLink onClick={() => onSelect(recommendedModel)}>{recommendedModel}.</VSCodeLink>
+				<VSCodeLink onClick={() => onSelect(defaultModelId)}>{defaultModelId}.</VSCodeLink>
 				You can also try searching "free" for no-cost options currently available.
 			</p>
 		</>

+ 19 - 29
webview-ui/src/components/settings/SettingsView.tsx

@@ -66,21 +66,20 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		terminalOutputLineLimit,
 		writeDelayMs,
 	} = cachedState
-	
+
 	//Make sure apiConfiguration is initialized and managed by SettingsView
 	const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
 
 	useEffect(() => {
-		// Update only when currentApiConfigName is changed
-		// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration
+		// Update only when currentApiConfigName is changed.
+		// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration.
 		if (prevApiConfigName.current === currentApiConfigName) {
 			return
 		}
-		setCachedState((prevCachedState) => ({
-			...prevCachedState,
-			...extensionState,
-		}))
+
+		setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
 		prevApiConfigName.current = currentApiConfigName
+		// console.log("useEffect: currentApiConfigName changed, setChangeDetected -> false")
 		setChangeDetected(false)
 	}, [currentApiConfigName, extensionState, isChangeDetected])
 
@@ -90,11 +89,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 				if (prevState[field] === value) {
 					return prevState
 				}
+
+				// console.log(`setCachedStateField(${field} -> ${value}): setChangeDetected -> true`)
 				setChangeDetected(true)
-				return {
-					...prevState,
-					[field]: value,
-				}
+				return { ...prevState, [field]: value }
 			})
 		},
 		[],
@@ -107,15 +105,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 					return prevState
 				}
 
+				// console.log(`setApiConfigurationField(${field} -> ${value}): setChangeDetected -> true`)
 				setChangeDetected(true)
 
-				return {
-					...prevState,
-					apiConfiguration: {
-						...prevState.apiConfiguration,
-						[field]: value,
-					},
-				}
+				return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } }
 			})
 		},
 		[],
@@ -126,14 +119,19 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			if (prevState.experiments?.[id] === enabled) {
 				return prevState
 			}
+
+			// console.log("setExperimentEnabled: setChangeDetected -> true")
 			setChangeDetected(true)
+
 			return {
 				...prevState,
 				experiments: { ...prevState.experiments, [id]: enabled },
 			}
 		})
 	}, [])
+
 	const isSettingValid = !errorMessage
+
 	const handleSubmit = () => {
 		if (isSettingValid) {
 			vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
@@ -160,6 +158,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			vscode.postMessage({ type: "updateExperimental", values: experiments })
 			vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
 			vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
+			// console.log("handleSubmit: setChangeDetected -> false")
 			setChangeDetected(false)
 		}
 	}
@@ -176,13 +175,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		[isChangeDetected],
 	)
 
-	useImperativeHandle(
-		ref,
-		() => ({
-			checkUnsaveChanges,
-		}),
-		[checkUnsaveChanges],
-	)
+	useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges])
 
 	const onConfirmDialogResult = useCallback((confirm: boolean) => {
 		if (confirm) {
@@ -200,10 +193,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			const newCommands = [...currentCommands, commandInput]
 			setCachedStateField("allowedCommands", newCommands)
 			setCommandInput("")
-			vscode.postMessage({
-				type: "allowedCommands",
-				commands: newCommands,
-			})
+			vscode.postMessage({ type: "allowedCommands", commands: newCommands })
 		}
 	}
 

+ 29 - 0
webview-ui/src/components/settings/ThinkingBudget.tsx

@@ -0,0 +1,29 @@
+import { Slider } from "@/components/ui"
+
+import { ApiConfiguration, ModelInfo, THINKING_BUDGET } from "../../../../src/shared/api"
+
+interface ThinkingBudgetProps {
+	apiConfiguration: ApiConfiguration
+	setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
+	modelInfo?: ModelInfo
+}
+
+export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => {
+	const budget = apiConfiguration?.anthropicThinking ?? THINKING_BUDGET.default
+
+	return modelInfo && modelInfo.thinking ? (
+		<div className="flex flex-col gap-1 mt-2">
+			<div className="font-medium">Thinking Budget</div>
+			<div className="flex items-center gap-1">
+				<Slider
+					min={THINKING_BUDGET.min}
+					max={(modelInfo.maxTokens ?? THINKING_BUDGET.default) - 1}
+					step={THINKING_BUDGET.step}
+					value={[budget]}
+					onValueChange={(value) => setApiConfigurationField("anthropicThinking", value[0])}
+				/>
+				<div className="w-12 text-sm text-center">{budget}</div>
+			</div>
+		</div>
+	) : null
+}

+ 77 - 74
webview-ui/src/components/ui/alert-dialog.tsx

@@ -4,94 +4,97 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
 import { cn } from "@/lib/utils"
 import { buttonVariants } from "@/components/ui/button"
 
-const AlertDialog = AlertDialogPrimitive.Root
-
-const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
+	return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
+}
 
-const AlertDialogPortal = AlertDialogPrimitive.Portal
+function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
+	return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
+}
 
-const AlertDialogOverlay = React.forwardRef<
-	React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
-	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
->(({ className, ...props }, ref) => (
-	<AlertDialogPrimitive.Overlay
-		className={cn(
-			"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
-			className,
-		)}
-		{...props}
-		ref={ref}
-	/>
-))
-AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
+	return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
+}
 
-const AlertDialogContent = React.forwardRef<
-	React.ElementRef<typeof AlertDialogPrimitive.Content>,
-	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
->(({ className, ...props }, ref) => (
-	<AlertDialogPortal>
-		<AlertDialogOverlay />
-		<AlertDialogPrimitive.Content
-			ref={ref}
+function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
+	return (
+		<AlertDialogPrimitive.Overlay
+			data-slot="alert-dialog-overlay"
 			className={cn(
-				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
+				"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
 				className,
 			)}
 			{...props}
 		/>
-	</AlertDialogPortal>
-))
-AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+	)
+}
 
-const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
-	<div className={cn("flex flex-col space-y-2 text-left", className)} {...props} />
-)
-AlertDialogHeader.displayName = "AlertDialogHeader"
+function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
+	return (
+		<AlertDialogPortal>
+			<AlertDialogOverlay />
+			<AlertDialogPrimitive.Content
+				data-slot="alert-dialog-content"
+				className={cn(
+					"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+					className,
+				)}
+				{...props}
+			/>
+		</AlertDialogPortal>
+	)
+}
 
-const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
-	<div className={cn("flex flex-row justify-end space-x-2", className)} {...props} />
-)
-AlertDialogFooter.displayName = "AlertDialogFooter"
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+	return (
+		<div
+			data-slot="alert-dialog-header"
+			className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+			{...props}
+		/>
+	)
+}
 
-const AlertDialogTitle = React.forwardRef<
-	React.ElementRef<typeof AlertDialogPrimitive.Title>,
-	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
->(({ className, ...props }, ref) => (
-	<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
-))
-AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+	return (
+		<div
+			data-slot="alert-dialog-footer"
+			className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
+			{...props}
+		/>
+	)
+}
+
+function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
+	return (
+		<AlertDialogPrimitive.Title
+			data-slot="alert-dialog-title"
+			className={cn("text-lg font-semibold", className)}
+			{...props}
+		/>
+	)
+}
 
-const AlertDialogDescription = React.forwardRef<
-	React.ElementRef<typeof AlertDialogPrimitive.Description>,
-	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
->(({ className, ...props }, ref) => (
-	<AlertDialogPrimitive.Description
-		ref={ref}
-		className={cn("text-base text-muted-foreground", className)}
-		{...props}
-	/>
-))
-AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
+function AlertDialogDescription({
+	className,
+	...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
+	return (
+		<AlertDialogPrimitive.Description
+			data-slot="alert-dialog-description"
+			className={cn("text-muted-foreground text-sm", className)}
+			{...props}
+		/>
+	)
+}
 
-const AlertDialogAction = React.forwardRef<
-	React.ElementRef<typeof AlertDialogPrimitive.Action>,
-	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
->(({ className, ...props }, ref) => (
-	<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
-))
-AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
+	return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
+}
 
-const AlertDialogCancel = React.forwardRef<
-	React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
-	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
->(({ className, ...props }, ref) => (
-	<AlertDialogPrimitive.Cancel
-		ref={ref}
-		className={cn(buttonVariants({ variant: "secondary" }), "mt-0", className)}
-		{...props}
-	/>
-))
-AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
+	return <AlertDialogPrimitive.Cancel className={cn(buttonVariants({ variant: "outline" }), className)} {...props} />
+}
 
 export {
 	AlertDialog,

+ 84 - 72
webview-ui/src/components/ui/dialog.tsx

@@ -1,96 +1,108 @@
-"use client"
-
 import * as React from "react"
 import * as DialogPrimitive from "@radix-ui/react-dialog"
-import { Cross2Icon } from "@radix-ui/react-icons"
+import { XIcon } from "lucide-react"
 
 import { cn } from "@/lib/utils"
 
-const Dialog = DialogPrimitive.Root
-
-const DialogTrigger = DialogPrimitive.Trigger
+function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
+	return <DialogPrimitive.Root data-slot="dialog" {...props} />
+}
 
-const DialogPortal = DialogPrimitive.Portal
+function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+	return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
+}
 
-const DialogClose = DialogPrimitive.Close
+function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+	return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
+}
 
-const DialogOverlay = React.forwardRef<
-	React.ElementRef<typeof DialogPrimitive.Overlay>,
-	React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
->(({ className, ...props }, ref) => (
-	<DialogPrimitive.Overlay
-		ref={ref}
-		className={cn(
-			"fixed inset-0 z-50 bg-black/50  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
-			className,
-		)}
-		{...props}
-	/>
-))
-DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
+	return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
+}
 
-const DialogContent = React.forwardRef<
-	React.ElementRef<typeof DialogPrimitive.Content>,
-	React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
->(({ className, children, ...props }, ref) => (
-	<DialogPortal>
-		<DialogOverlay />
-		<DialogPrimitive.Content
-			ref={ref}
+function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+	return (
+		<DialogPrimitive.Overlay
+			data-slot="dialog-overlay"
 			className={cn(
-				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
+				"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
 				className,
 			)}
-			{...props}>
-			{children}
-			<DialogPrimitive.Close className="cursor-pointer absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
-				<Cross2Icon className="h-4 w-4" />
-				<span className="sr-only">Close</span>
-			</DialogPrimitive.Close>
-		</DialogPrimitive.Content>
-	</DialogPortal>
-))
-DialogContent.displayName = DialogPrimitive.Content.displayName
+			{...props}
+		/>
+	)
+}
 
-const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
-	<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
-)
-DialogHeader.displayName = "DialogHeader"
+function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
+	return (
+		<DialogPortal data-slot="dialog-portal">
+			<DialogOverlay />
+			<DialogPrimitive.Content
+				data-slot="dialog-content"
+				className={cn(
+					"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+					className,
+				)}
+				{...props}>
+				{children}
+				<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
+					<XIcon />
+					<span className="sr-only">Close</span>
+				</DialogPrimitive.Close>
+			</DialogPrimitive.Content>
+		</DialogPortal>
+	)
+}
 
-const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
-	<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
-)
-DialogFooter.displayName = "DialogFooter"
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+	return (
+		<div
+			data-slot="dialog-header"
+			className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+			{...props}
+		/>
+	)
+}
 
-const DialogTitle = React.forwardRef<
-	React.ElementRef<typeof DialogPrimitive.Title>,
-	React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
->(({ className, ...props }, ref) => (
-	<DialogPrimitive.Title
-		ref={ref}
-		className={cn("text-lg font-semibold leading-none tracking-tight", className)}
-		{...props}
-	/>
-))
-DialogTitle.displayName = DialogPrimitive.Title.displayName
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+	return (
+		<div
+			data-slot="dialog-footer"
+			className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
+			{...props}
+		/>
+	)
+}
 
-const DialogDescription = React.forwardRef<
-	React.ElementRef<typeof DialogPrimitive.Description>,
-	React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
->(({ className, ...props }, ref) => (
-	<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
-))
-DialogDescription.displayName = DialogPrimitive.Description.displayName
+function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
+	return (
+		<DialogPrimitive.Title
+			data-slot="dialog-title"
+			className={cn("text-lg leading-none font-semibold", className)}
+			{...props}
+		/>
+	)
+}
+
+function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
+	return (
+		<DialogPrimitive.Description
+			data-slot="dialog-description"
+			className={cn("text-muted-foreground text-sm", className)}
+			{...props}
+		/>
+	)
+}
 
 export {
 	Dialog,
-	DialogPortal,
-	DialogOverlay,
-	DialogTrigger,
 	DialogClose,
 	DialogContent,
-	DialogHeader,
+	DialogDescription,
 	DialogFooter,
+	DialogHeader,
+	DialogOverlay,
+	DialogPortal,
 	DialogTitle,
-	DialogDescription,
+	DialogTrigger,
 }

+ 3 - 5
webview-ui/src/components/welcome/WelcomeView.tsx

@@ -12,16 +12,14 @@ const WelcomeView = () => {
 
 	const handleSubmit = useCallback(() => {
 		const error = validateApiConfiguration(apiConfiguration)
+
 		if (error) {
 			setErrorMessage(error)
 			return
 		}
+
 		setErrorMessage(undefined)
-		vscode.postMessage({
-			type: "upsertApiConfiguration",
-			text: currentApiConfigName,
-			apiConfiguration,
-		})
+		vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
 	}, [apiConfiguration, currentApiConfigName])
 
 	return (

+ 152 - 102
webview-ui/src/utils/validate.ts

@@ -1,74 +1,83 @@
-import { ApiConfiguration } from "../../../src/shared/api"
-import { ModelInfo } from "../../../src/shared/api"
+import { ApiConfiguration, ModelInfo } from "../../../src/shared/api"
+
 export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
-	if (apiConfiguration) {
-		switch (apiConfiguration.apiProvider) {
-			case "anthropic":
-				if (!apiConfiguration.apiKey) {
-					return "You must provide a valid API key or choose a different provider."
-				}
-				break
-			case "glama":
-				if (!apiConfiguration.glamaApiKey) {
-					return "You must provide a valid API key or choose a different provider."
-				}
-				break
-			case "bedrock":
-				if (!apiConfiguration.awsRegion) {
-					return "You must choose a region to use with AWS Bedrock."
-				}
-				break
-			case "openrouter":
-				if (!apiConfiguration.openRouterApiKey) {
-					return "You must provide a valid API key or choose a different provider."
-				}
-				break
-			case "vertex":
-				if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) {
-					return "You must provide a valid Google Cloud Project ID and Region."
-				}
-				break
-			case "gemini":
-				if (!apiConfiguration.geminiApiKey) {
-					return "You must provide a valid API key or choose a different provider."
-				}
-				break
-			case "openai-native":
-				if (!apiConfiguration.openAiNativeApiKey) {
-					return "You must provide a valid API key or choose a different provider."
-				}
-				break
-			case "mistral":
-				if (!apiConfiguration.mistralApiKey) {
-					return "You must provide a valid API key or choose a different provider."
-				}
-				break
-			case "openai":
-				if (
-					!apiConfiguration.openAiBaseUrl ||
-					!apiConfiguration.openAiApiKey ||
-					!apiConfiguration.openAiModelId
-				) {
-					return "You must provide a valid base URL, API key, and model ID."
-				}
-				break
-			case "ollama":
-				if (!apiConfiguration.ollamaModelId) {
-					return "You must provide a valid model ID."
-				}
-				break
-			case "lmstudio":
-				if (!apiConfiguration.lmStudioModelId) {
-					return "You must provide a valid model ID."
-				}
-				break
-			case "vscode-lm":
-				if (!apiConfiguration.vsCodeLmModelSelector) {
-					return "You must provide a valid model selector."
-				}
-				break
-		}
+	if (!apiConfiguration) {
+		return undefined
+	}
+
+	switch (apiConfiguration.apiProvider) {
+		case "openrouter":
+			if (!apiConfiguration.openRouterApiKey) {
+				return "You must provide a valid API key."
+			}
+			break
+		case "glama":
+			if (!apiConfiguration.glamaApiKey) {
+				return "You must provide a valid API key."
+			}
+			break
+		case "unbound":
+			if (!apiConfiguration.unboundApiKey) {
+				return "You must provide a valid API key."
+			}
+			break
+		case "requesty":
+			if (!apiConfiguration.requestyApiKey) {
+				return "You must provide a valid API key."
+			}
+			break
+		case "anthropic":
+			if (!apiConfiguration.apiKey) {
+				return "You must provide a valid API key."
+			}
+			break
+		case "bedrock":
+			if (!apiConfiguration.awsRegion) {
+				return "You must choose a region to use with AWS Bedrock."
+			}
+			break
+		case "vertex":
+			if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) {
+				return "You must provide a valid Google Cloud Project ID and Region."
+			}
+			break
+		case "gemini":
+			if (!apiConfiguration.geminiApiKey) {
+				return "You must provide a valid API key."
+			}
+			break
+		case "openai-native":
+			if (!apiConfiguration.openAiNativeApiKey) {
+				return "You must provide a valid API key."
+			}
+			break
+		case "mistral":
+			if (!apiConfiguration.mistralApiKey) {
+				return "You must provide a valid API key."
+			}
+			break
+		case "openai":
+			if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) {
+				return "You must provide a valid base URL, API key, and model ID."
+			}
+			break
+		case "ollama":
+			if (!apiConfiguration.ollamaModelId) {
+				return "You must provide a valid model ID."
+			}
+			break
+		case "lmstudio":
+			if (!apiConfiguration.lmStudioModelId) {
+				return "You must provide a valid model ID."
+			}
+			break
+		case "vscode-lm":
+			if (!apiConfiguration.vsCodeLmModelSelector) {
+				return "You must provide a valid model selector."
+			}
+			break
 	}
+
 	return undefined
 }
 
@@ -77,40 +86,81 @@ export function validateModelId(
 	glamaModels?: Record<string, ModelInfo>,
 	openRouterModels?: Record<string, ModelInfo>,
 	unboundModels?: Record<string, ModelInfo>,
+	requestyModels?: Record<string, ModelInfo>,
 ): string | undefined {
-	if (apiConfiguration) {
-		switch (apiConfiguration.apiProvider) {
-			case "glama":
-				const glamaModelId = apiConfiguration.glamaModelId
-				if (!glamaModelId) {
-					return "You must provide a model ID."
-				}
-				if (glamaModels && !Object.keys(glamaModels).includes(glamaModelId)) {
-					// even if the model list endpoint failed, extensionstatecontext will always have the default model info
-					return "The model ID you provided is not available. Please choose a different model."
-				}
-				break
-			case "openrouter":
-				const modelId = apiConfiguration.openRouterModelId
-				if (!modelId) {
-					return "You must provide a model ID."
-				}
-				if (openRouterModels && !Object.keys(openRouterModels).includes(modelId)) {
-					// even if the model list endpoint failed, extensionstatecontext will always have the default model info
-					return "The model ID you provided is not available. Please choose a different model."
-				}
-				break
-			case "unbound":
-				const unboundModelId = apiConfiguration.unboundModelId
-				if (!unboundModelId) {
-					return "You must provide a model ID."
-				}
-				if (unboundModels && !Object.keys(unboundModels).includes(unboundModelId)) {
-					// even if the model list endpoint failed, extensionstatecontext will always have the default model info
-					return "The model ID you provided is not available. Please choose a different model."
-				}
-				break
-		}
+	if (!apiConfiguration) {
+		return undefined
+	}
+
+	switch (apiConfiguration.apiProvider) {
+		case "openrouter":
+			const modelId = apiConfiguration.openRouterModelId
+
+			if (!modelId) {
+				return "You must provide a model ID."
+			}
+
+			if (
+				openRouterModels &&
+				Object.keys(openRouterModels).length > 1 &&
+				!Object.keys(openRouterModels).includes(modelId)
+			) {
+				return `The model ID (${modelId}) you provided is not available. Please choose a different model.`
+			}
+
+			break
+
+		case "glama":
+			const glamaModelId = apiConfiguration.glamaModelId
+
+			if (!glamaModelId) {
+				return "You must provide a model ID."
+			}
+
+			if (
+				glamaModels &&
+				Object.keys(glamaModels).length > 1 &&
+				!Object.keys(glamaModels).includes(glamaModelId)
+			) {
+				return `The model ID (${glamaModelId}) you provided is not available. Please choose a different model.`
+			}
+
+			break
+
+		case "unbound":
+			const unboundModelId = apiConfiguration.unboundModelId
+
+			if (!unboundModelId) {
+				return "You must provide a model ID."
+			}
+
+			if (
+				unboundModels &&
+				Object.keys(unboundModels).length > 1 &&
+				!Object.keys(unboundModels).includes(unboundModelId)
+			) {
+				return `The model ID (${unboundModelId}) you provided is not available. Please choose a different model.`
+			}
+
+			break
+
+		case "requesty":
+			const requestyModelId = apiConfiguration.requestyModelId
+
+			if (!requestyModelId) {
+				return "You must provide a model ID."
+			}
+
+			if (
+				requestyModels &&
+				Object.keys(requestyModels).length > 1 &&
+				!Object.keys(requestyModels).includes(requestyModelId)
+			) {
+				return `The model ID (${requestyModelId}) you provided is not available. Please choose a different model.`
+			}
+
+			break
 	}
+
 	return undefined
 }