Sfoglia il codice sorgente

feat: standardize model selectors across all providers (#10294)

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
Co-authored-by: Roo Code <[email protected]>
Hannes Rudolph 3 settimane fa
parent
commit
e356d058e9

+ 37 - 96
webview-ui/src/components/settings/ApiOptions.tsx

@@ -41,6 +41,14 @@ import {
 	minimaxDefaultModelId,
 } from "@roo-code/types"
 
+import {
+	getProviderServiceConfig,
+	getDefaultModelIdForProvider,
+	getStaticModelsForProvider,
+	shouldUseGenericModelPicker,
+	handleModelChangeSideEffects,
+} from "./utils/providerModelConfig"
+
 import { vscode } from "@src/utils/vscode"
 import { validateApiConfigurationExcludingModelErrors, getModelValidationError } from "@src/utils/validate"
 import { useAppTranslation } from "@src/i18n/TranslationContext"
@@ -104,7 +112,7 @@ import {
 
 import { MODELS_BY_PROVIDER, PROVIDERS } from "./constants"
 import { inputEventTransform, noTransform } from "./transforms"
-import { ModelInfoView } from "./ModelInfoView"
+import { ModelPicker } from "./ModelPicker"
 import { ApiErrorMessage } from "./ApiErrorMessage"
 import { ThinkingBudget } from "./ThinkingBudget"
 import { Verbosity } from "./Verbosity"
@@ -174,7 +182,6 @@ const ApiOptions = ({
 		[customHeaders, apiConfiguration?.openAiHeaders, setApiConfigurationField],
 	)
 
-	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
 	const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false)
 
 	const handleInputChange = useCallback(
@@ -273,32 +280,6 @@ const ApiOptions = ({
 		setErrorMessage(apiValidationResult)
 	}, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage])
 
-	const selectedProviderModels = useMemo(() => {
-		const models = MODELS_BY_PROVIDER[selectedProvider]
-
-		if (!models) return []
-
-		const filteredModels = filterModels(models, selectedProvider, organizationAllowList)
-
-		// Include the currently selected model even if deprecated (so users can see what they have selected)
-		// But filter out other deprecated models from being newly selectable
-		const availableModels = filteredModels
-			? Object.entries(filteredModels)
-					.filter(([modelId, modelInfo]) => {
-						// Always include the currently selected model
-						if (modelId === selectedModelId) return true
-						// Filter out deprecated models that aren't currently selected
-						return !modelInfo.deprecated
-					})
-					.map(([modelId]) => ({
-						value: modelId,
-						label: modelId,
-					}))
-			: []
-
-		return availableModels
-	}, [selectedProvider, organizationAllowList, selectedModelId])
-
 	const onProviderChange = useCallback(
 		(value: ProviderName) => {
 			setApiConfigurationField("apiProvider", value)
@@ -660,11 +641,7 @@ const ApiOptions = ({
 			)}
 
 			{selectedProvider === "lmstudio" && (
-				<LMStudio
-					apiConfiguration={apiConfiguration}
-					setApiConfigurationField={setApiConfigurationField}
-					simplifySettings={fromWelcomeView}
-				/>
+				<LMStudio apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
 			)}
 
 			{selectedProvider === "deepseek" && (
@@ -797,69 +774,33 @@ const ApiOptions = ({
 				<Featherless apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
 			)}
 
-			{/* Skip generic model picker for claude-code/openai-codex since they have their own model pickers */}
-			{selectedProviderModels.length > 0 &&
-				selectedProvider !== "claude-code" &&
-				selectedProvider !== "openai-codex" && (
-					<>
-						<div>
-							<label className="block font-medium mb-1">{t("settings:providers.model")}</label>
-							<Select
-								value={selectedModelId === "custom-arn" ? "custom-arn" : selectedModelId}
-								onValueChange={(value) => {
-									setApiConfigurationField("apiModelId", value)
-
-									// Clear custom ARN if not using custom ARN option.
-									if (value !== "custom-arn" && selectedProvider === "bedrock") {
-										setApiConfigurationField("awsCustomArn", "")
-									}
-
-									// Clear reasoning effort when switching models to allow the new model's default to take effect
-									// This is especially important for GPT-5 models which default to "medium"
-									if (selectedProvider === "openai-native") {
-										setApiConfigurationField("reasoningEffort", undefined)
-									}
-								}}>
-								<SelectTrigger className="w-full">
-									<SelectValue placeholder={t("settings:common.select")} />
-								</SelectTrigger>
-								<SelectContent>
-									{selectedProviderModels.map((option) => (
-										<SelectItem key={option.value} value={option.value}>
-											{option.label}
-										</SelectItem>
-									))}
-									{selectedProvider === "bedrock" && (
-										<SelectItem value="custom-arn">{t("settings:labels.useCustomArn")}</SelectItem>
-									)}
-								</SelectContent>
-							</Select>
-						</div>
-
-						{/* Show error if a deprecated model is selected */}
-						{selectedModelInfo?.deprecated && (
-							<ApiErrorMessage errorMessage={t("settings:validation.modelDeprecated")} />
-						)}
-
-						{selectedProvider === "bedrock" && selectedModelId === "custom-arn" && (
-							<BedrockCustomArn
-								apiConfiguration={apiConfiguration}
-								setApiConfigurationField={setApiConfigurationField}
-							/>
-						)}
-
-						{/* Only show model info if not deprecated */}
-						{!selectedModelInfo?.deprecated && (
-							<ModelInfoView
-								apiProvider={selectedProvider}
-								selectedModelId={selectedModelId}
-								modelInfo={selectedModelInfo}
-								isDescriptionExpanded={isDescriptionExpanded}
-								setIsDescriptionExpanded={setIsDescriptionExpanded}
-							/>
-						)}
-					</>
-				)}
+			{/* Generic model picker for providers with static models */}
+			{shouldUseGenericModelPicker(selectedProvider) && (
+				<>
+					<ModelPicker
+						apiConfiguration={apiConfiguration}
+						setApiConfigurationField={setApiConfigurationField}
+						defaultModelId={getDefaultModelIdForProvider(selectedProvider, apiConfiguration)}
+						models={getStaticModelsForProvider(selectedProvider, t("settings:labels.useCustomArn"))}
+						modelIdKey="apiModelId"
+						serviceName={getProviderServiceConfig(selectedProvider).serviceName}
+						serviceUrl={getProviderServiceConfig(selectedProvider).serviceUrl}
+						organizationAllowList={organizationAllowList}
+						errorMessage={modelValidationError}
+						simplifySettings={fromWelcomeView}
+						onModelChange={(modelId) =>
+							handleModelChangeSideEffects(selectedProvider, modelId, setApiConfigurationField)
+						}
+					/>
+
+					{selectedProvider === "bedrock" && selectedModelId === "custom-arn" && (
+						<BedrockCustomArn
+							apiConfiguration={apiConfiguration}
+							setApiConfigurationField={setApiConfigurationField}
+						/>
+					)}
+				</>
+			)}
 
 			{!fromWelcomeView && (
 				<ThinkingBudget

+ 37 - 5
webview-ui/src/components/settings/ModelPicker.tsx

@@ -37,6 +37,10 @@ type ModelIdKey = keyof Pick<
 	| "ioIntelligenceModelId"
 	| "vercelAiGatewayModelId"
 	| "apiModelId"
+	| "ollamaModelId"
+	| "lmStudioModelId"
+	| "lmStudioDraftModelId"
+	| "vsCodeLmModelSelector"
 >
 
 interface ModelPickerProps {
@@ -55,6 +59,14 @@ interface ModelPickerProps {
 	errorMessage?: string
 	simplifySettings?: boolean
 	hidePricing?: boolean
+	/** Label for the model picker field - defaults to "Model" */
+	label?: string
+	/** Transform model ID string to the value stored in configuration (for compound types like VSCodeLM selector) */
+	valueTransform?: (modelId: string) => unknown
+	/** Transform stored configuration value back to display string */
+	displayTransform?: (value: unknown) => string
+	/** Callback when model changes - useful for side effects like clearing related fields */
+	onModelChange?: (modelId: string) => void
 }
 
 export const ModelPicker = ({
@@ -69,6 +81,10 @@ export const ModelPicker = ({
 	errorMessage,
 	simplifySettings,
 	hidePricing,
+	label,
+	valueTransform,
+	displayTransform,
+	onModelChange,
 }: ModelPickerProps) => {
 	const { t } = useAppTranslation()
 
@@ -81,6 +97,16 @@ export const ModelPicker = ({
 
 	const { id: selectedModelId, info: selectedModelInfo } = useSelectedModel(apiConfiguration)
 
+	// Get the display value for the current selection
+	// If displayTransform is provided, use it to convert the stored value to a display string
+	const displayValue = useMemo(() => {
+		if (displayTransform) {
+			const storedValue = apiConfiguration[modelIdKey]
+			return storedValue ? displayTransform(storedValue) : undefined
+		}
+		return selectedModelId
+	}, [displayTransform, apiConfiguration, modelIdKey, selectedModelId])
+
 	const modelIds = useMemo(() => {
 		const filteredModels = filterModels(models, apiConfiguration.apiProvider, organizationAllowList)
 
@@ -113,7 +139,13 @@ export const ModelPicker = ({
 			}
 
 			setOpen(false)
-			setApiConfigurationField(modelIdKey, modelId)
+
+			// Apply value transform if provided (e.g., for VSCodeLM selector)
+			const valueToStore = valueTransform ? valueTransform(modelId) : modelId
+			setApiConfigurationField(modelIdKey, valueToStore as ProviderSettings[ModelIdKey])
+
+			// Call the optional change callback
+			onModelChange?.(modelId)
 
 			// Clear any existing timeout
 			if (selectTimeoutRef.current) {
@@ -123,7 +155,7 @@ export const ModelPicker = ({
 			// Delay to ensure the popover is closed before setting the search value.
 			selectTimeoutRef.current = setTimeout(() => setSearchValue(""), 100)
 		},
-		[modelIdKey, setApiConfigurationField],
+		[modelIdKey, setApiConfigurationField, valueTransform, onModelChange],
 	)
 
 	const onOpenChange = useCallback((open: boolean) => {
@@ -173,7 +205,7 @@ export const ModelPicker = ({
 	return (
 		<>
 			<div>
-				<label className="block font-medium mb-1">{t("settings:modelPicker.label")}</label>
+				<label className="block font-medium mb-1">{label ?? t("settings:modelPicker.label")}</label>
 				<Popover open={open} onOpenChange={onOpenChange}>
 					<PopoverTrigger asChild>
 						<Button
@@ -182,7 +214,7 @@ export const ModelPicker = ({
 							aria-expanded={open}
 							className="w-full justify-between"
 							data-testid="model-picker-button">
-							<div className="truncate">{selectedModelId ?? t("settings:common.select")}</div>
+							<div className="truncate">{displayValue ?? t("settings:common.select")}</div>
 							<ChevronsUpDown className="opacity-50" />
 						</Button>
 					</PopoverTrigger>
@@ -227,7 +259,7 @@ export const ModelPicker = ({
 											<Check
 												className={cn(
 													"size-4 p-0.5 ml-auto",
-													model === selectedModelId ? "opacity-100" : "opacity-0",
+													model === displayValue ? "opacity-100" : "opacity-0",
 												)}
 											/>
 										</CommandItem>

+ 11 - 0
webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx

@@ -80,6 +80,17 @@ vi.mock("@src/components/ui", () => ({
 	CollapsibleContent: ({ children }: any) => <div>{children}</div>,
 	Slider: ({ children, ...props }: any) => <div {...props}>{children}</div>,
 	Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
+	// Add Popover components for ModelPicker
+	Popover: ({ children }: any) => <div>{children}</div>,
+	PopoverTrigger: ({ children }: any) => <div>{children}</div>,
+	PopoverContent: ({ children }: any) => <div>{children}</div>,
+	// Add Command components for ModelPicker
+	Command: ({ children }: any) => <div>{children}</div>,
+	CommandInput: ({ ...props }: any) => <input {...props} />,
+	CommandList: ({ children }: any) => <div>{children}</div>,
+	CommandEmpty: ({ children }: any) => <div>{children}</div>,
+	CommandGroup: ({ children }: any) => <div>{children}</div>,
+	CommandItem: ({ children, ...props }: any) => <div {...props}>{children}</div>,
 }))
 
 describe("ApiOptions Provider Filtering", () => {

+ 43 - 100
webview-ui/src/components/settings/providers/LMStudio.tsx

@@ -2,7 +2,7 @@ import { useCallback, useState, useMemo, useEffect } from "react"
 import { useEvent } from "react-use"
 import { Trans } from "react-i18next"
 import { Checkbox } from "vscrui"
-import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
+import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
 import type { ProviderSettings, ExtensionMessage, ModelRecord } from "@roo-code/types"
 
@@ -11,11 +11,11 @@ import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
 import { vscode } from "@src/utils/vscode"
 
 import { inputEventTransform } from "../transforms"
+import { ModelPicker } from "../ModelPicker"
 
 type LMStudioProps = {
 	apiConfiguration: ProviderSettings
 	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
-	simplifySettings?: boolean
 }
 
 export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudioProps) => {
@@ -57,46 +57,50 @@ export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudi
 	}, [])
 
 	// Check if the selected model exists in the fetched models
-	const modelNotAvailable = useMemo(() => {
+	const modelNotAvailableError = useMemo(() => {
 		const selectedModel = apiConfiguration?.lmStudioModelId
-		if (!selectedModel) return false
+		if (!selectedModel) return undefined
 
 		// Check if model exists in local LM Studio models
 		if (Object.keys(lmStudioModels).length > 0 && selectedModel in lmStudioModels) {
-			return false // Model is available locally
+			return undefined // Model is available locally
 		}
 
 		// If we have router models data for LM Studio
 		if (routerModels.data?.lmstudio) {
 			const availableModels = Object.keys(routerModels.data.lmstudio)
 			// Show warning if model is not in the list (regardless of how many models there are)
-			return !availableModels.includes(selectedModel)
+			if (!availableModels.includes(selectedModel)) {
+				return t("settings:validation.modelAvailability", { modelId: selectedModel })
+			}
 		}
 
 		// If neither source has loaded yet, don't show warning
-		return false
-	}, [apiConfiguration?.lmStudioModelId, routerModels.data, lmStudioModels])
+		return undefined
+	}, [apiConfiguration?.lmStudioModelId, routerModels.data, lmStudioModels, t])
 
 	// Check if the draft model exists
-	const draftModelNotAvailable = useMemo(() => {
+	const draftModelNotAvailableError = useMemo(() => {
 		const draftModel = apiConfiguration?.lmStudioDraftModelId
-		if (!draftModel) return false
+		if (!draftModel) return undefined
 
 		// Check if model exists in local LM Studio models
 		if (Object.keys(lmStudioModels).length > 0 && draftModel in lmStudioModels) {
-			return false // Model is available locally
+			return undefined // Model is available locally
 		}
 
 		// If we have router models data for LM Studio
 		if (routerModels.data?.lmstudio) {
 			const availableModels = Object.keys(routerModels.data.lmstudio)
 			// Show warning if model is not in the list (regardless of how many models there are)
-			return !availableModels.includes(draftModel)
+			if (!availableModels.includes(draftModel)) {
+				return t("settings:validation.modelAvailability", { modelId: draftModel })
+			}
 		}
 
 		// If neither source has loaded yet, don't show warning
-		return false
-	}, [apiConfiguration?.lmStudioDraftModelId, routerModels.data, lmStudioModels])
+		return undefined
+	}, [apiConfiguration?.lmStudioDraftModelId, routerModels.data, lmStudioModels, t])
 
 	return (
 		<>
@@ -108,38 +112,17 @@ export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudi
 				className="w-full">
 				<label className="block font-medium mb-1">{t("settings:providers.lmStudio.baseUrl")}</label>
 			</VSCodeTextField>
-			<VSCodeTextField
-				value={apiConfiguration?.lmStudioModelId || ""}
-				onInput={handleInputChange("lmStudioModelId")}
-				placeholder={t("settings:placeholders.modelId.lmStudio")}
-				className="w-full">
-				<label className="block font-medium mb-1">{t("settings:providers.lmStudio.modelId")}</label>
-			</VSCodeTextField>
-			{modelNotAvailable && (
-				<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>
-							{t("settings:validation.modelAvailability", { modelId: apiConfiguration?.lmStudioModelId })}
-						</div>
-					</div>
-				</div>
-			)}
-			{Object.keys(lmStudioModels).length > 0 && (
-				<VSCodeRadioGroup
-					value={
-						(apiConfiguration?.lmStudioModelId || "") in lmStudioModels
-							? apiConfiguration?.lmStudioModelId
-							: ""
-					}
-					onChange={handleInputChange("lmStudioModelId")}>
-					{Object.keys(lmStudioModels).map((model) => (
-						<VSCodeRadio key={model} value={model} checked={apiConfiguration?.lmStudioModelId === model}>
-							{model}
-						</VSCodeRadio>
-					))}
-				</VSCodeRadioGroup>
-			)}
+			<ModelPicker
+				apiConfiguration={apiConfiguration}
+				setApiConfigurationField={setApiConfigurationField}
+				defaultModelId=""
+				models={lmStudioModels}
+				modelIdKey="lmStudioModelId"
+				serviceName="LM Studio"
+				serviceUrl="https://lmstudio.ai/docs"
+				errorMessage={modelNotAvailableError}
+				hidePricing
+			/>
 			<Checkbox
 				checked={apiConfiguration?.lmStudioSpeculativeDecodingEnabled === true}
 				onChange={(checked) => {
@@ -149,61 +132,21 @@ export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudi
 			</Checkbox>
 			{apiConfiguration?.lmStudioSpeculativeDecodingEnabled && (
 				<>
-					<div>
-						<VSCodeTextField
-							value={apiConfiguration?.lmStudioDraftModelId || ""}
-							onInput={handleInputChange("lmStudioDraftModelId")}
-							placeholder={t("settings:placeholders.modelId.lmStudioDraft")}
-							className="w-full">
-							<label className="block font-medium mb-1">
-								{t("settings:providers.lmStudio.draftModelId")}
-							</label>
-						</VSCodeTextField>
-						<div className="text-sm text-vscode-descriptionForeground">
-							{t("settings:providers.lmStudio.draftModelDesc")}
-						</div>
-						{draftModelNotAvailable && (
-							<div className="flex flex-col gap-2 text-vscode-errorForeground text-sm mt-2">
-								<div className="flex flex-row items-center gap-1">
-									<div className="codicon codicon-close" />
-									<div>
-										{t("settings:validation.modelAvailability", {
-											modelId: apiConfiguration?.lmStudioDraftModelId,
-										})}
-									</div>
-								</div>
-							</div>
-						)}
+					<ModelPicker
+						apiConfiguration={apiConfiguration}
+						setApiConfigurationField={setApiConfigurationField}
+						defaultModelId=""
+						models={lmStudioModels}
+						modelIdKey="lmStudioDraftModelId"
+						serviceName="LM Studio"
+						serviceUrl="https://lmstudio.ai/docs"
+						label={t("settings:providers.lmStudio.draftModelId")}
+						errorMessage={draftModelNotAvailableError}
+						hidePricing
+					/>
+					<div className="text-sm text-vscode-descriptionForeground">
+						{t("settings:providers.lmStudio.draftModelDesc")}
 					</div>
-					{Object.keys(lmStudioModels).length > 0 && (
-						<>
-							<div className="font-medium">{t("settings:providers.lmStudio.selectDraftModel")}</div>
-							<VSCodeRadioGroup
-								value={
-									(apiConfiguration?.lmStudioDraftModelId || "") in lmStudioModels
-										? apiConfiguration?.lmStudioDraftModelId
-										: ""
-								}
-								onChange={handleInputChange("lmStudioDraftModelId")}>
-								{Object.keys(lmStudioModels).map((model) => (
-									<VSCodeRadio key={`draft-${model}`} value={model}>
-										{model}
-									</VSCodeRadio>
-								))}
-							</VSCodeRadioGroup>
-							{Object.keys(lmStudioModels).length === 0 && (
-								<div
-									className="text-sm rounded-xs p-2"
-									style={{
-										backgroundColor: "var(--vscode-inputValidation-infoBackground)",
-										border: "1px solid var(--vscode-inputValidation-infoBorder)",
-										color: "var(--vscode-inputValidation-infoForeground)",
-									}}>
-									{t("settings:providers.lmStudio.noModelsFound")}
-								</div>
-							)}
-						</>
-					)}
 				</>
 			)}
 			<div className="text-sm text-vscode-descriptionForeground">

+ 23 - 39
webview-ui/src/components/settings/providers/Ollama.tsx

@@ -1,6 +1,6 @@
 import { useState, useCallback, useMemo, useEffect } from "react"
 import { useEvent } from "react-use"
-import { VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
+import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
 import type { ProviderSettings, ExtensionMessage, ModelRecord } from "@roo-code/types"
 
@@ -9,6 +9,7 @@ import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
 import { vscode } from "@src/utils/vscode"
 
 import { inputEventTransform } from "../transforms"
+import { ModelPicker } from "../ModelPicker"
 
 type OllamaProps = {
 	apiConfiguration: ProviderSettings
@@ -54,25 +55,27 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
 	}, [])
 
 	// Check if the selected model exists in the fetched models
-	const modelNotAvailable = useMemo(() => {
+	const modelNotAvailableError = useMemo(() => {
 		const selectedModel = apiConfiguration?.ollamaModelId
-		if (!selectedModel) return false
+		if (!selectedModel) return undefined
 
 		// Check if model exists in local ollama models
 		if (Object.keys(ollamaModels).length > 0 && selectedModel in ollamaModels) {
-			return false // Model is available locally
+			return undefined // Model is available locally
 		}
 
 		// If we have router models data for Ollama
 		if (routerModels.data?.ollama) {
 			const availableModels = Object.keys(routerModels.data.ollama)
 			// Show warning if model is not in the list (regardless of how many models there are)
-			return !availableModels.includes(selectedModel)
+			if (!availableModels.includes(selectedModel)) {
+				return t("settings:validation.modelAvailability", { modelId: selectedModel })
+			}
 		}
 
 		// If neither source has loaded yet, don't show warning
-		return false
-	}, [apiConfiguration?.ollamaModelId, routerModels.data, ollamaModels])
+		return undefined
+	}, [apiConfiguration?.ollamaModelId, routerModels.data, ollamaModels, t])
 
 	return (
 		<>
@@ -97,40 +100,21 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
 					</div>
 				</VSCodeTextField>
 			)}
-			<VSCodeTextField
-				value={apiConfiguration?.ollamaModelId || ""}
-				onInput={handleInputChange("ollamaModelId")}
-				placeholder={t("settings:placeholders.modelId.ollama")}
-				className="w-full">
-				<label className="block font-medium mb-1">{t("settings:providers.ollama.modelId")}</label>
-			</VSCodeTextField>
-			{modelNotAvailable && (
-				<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>
-							{t("settings:validation.modelAvailability", { modelId: apiConfiguration?.ollamaModelId })}
-						</div>
-					</div>
-				</div>
-			)}
-			{Object.keys(ollamaModels).length > 0 && (
-				<VSCodeRadioGroup
-					value={
-						(apiConfiguration?.ollamaModelId || "") in ollamaModels ? apiConfiguration?.ollamaModelId : ""
-					}
-					onChange={handleInputChange("ollamaModelId")}>
-					{Object.keys(ollamaModels).map((model) => (
-						<VSCodeRadio key={model} value={model} checked={apiConfiguration?.ollamaModelId === model}>
-							{model}
-						</VSCodeRadio>
-					))}
-				</VSCodeRadioGroup>
-			)}
+			<ModelPicker
+				apiConfiguration={apiConfiguration}
+				setApiConfigurationField={setApiConfigurationField}
+				defaultModelId=""
+				models={ollamaModels}
+				modelIdKey="ollamaModelId"
+				serviceName="Ollama"
+				serviceUrl="https://ollama.ai"
+				errorMessage={modelNotAvailableError}
+				hidePricing
+			/>
 			<VSCodeTextField
 				value={apiConfiguration?.ollamaNumCtx?.toString() || ""}
-				onInput={(e: any) => {
-					const value = e.target?.value
+				onInput={(e) => {
+					const value = (e.target as HTMLInputElement)?.value
 					if (value === "") {
 						setApiConfigurationField("ollamaNumCtx", undefined)
 					} else {

+ 51 - 44
webview-ui/src/components/settings/providers/VSCodeLM.tsx

@@ -1,13 +1,12 @@
-import { useState, useCallback } from "react"
+import { useState, useCallback, useMemo } from "react"
 import { useEvent } from "react-use"
 import { LanguageModelChatSelector } from "vscode"
 
-import type { ProviderSettings, ExtensionMessage } from "@roo-code/types"
+import type { ProviderSettings, ExtensionMessage, ModelInfo } from "@roo-code/types"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui"
 
-import { inputEventTransform } from "../transforms"
+import { ModelPicker } from "../ModelPicker"
 
 type VSCodeLMProps = {
 	apiConfiguration: ProviderSettings
@@ -19,17 +18,6 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL
 
 	const [vsCodeLmModels, setVsCodeLmModels] = useState<LanguageModelChatSelector[]>([])
 
-	const handleInputChange = useCallback(
-		<K extends keyof ProviderSettings, E>(
-			field: K,
-			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
-		) =>
-			(event: E | Event) => {
-				setApiConfigurationField(field, transform(event as E))
-			},
-		[setApiConfigurationField],
-	)
-
 	const onMessage = useCallback((event: MessageEvent) => {
 		const message: ExtensionMessage = event.data
 
@@ -45,40 +33,59 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL
 
 	useEvent("message", onMessage)
 
+	// Convert VSCode LM models array to Record format for ModelPicker
+	const modelsRecord = useMemo((): Record<string, ModelInfo> => {
+		return vsCodeLmModels.reduce(
+			(acc, model) => {
+				const modelId = `${model.vendor}/${model.family}`
+				acc[modelId] = {
+					maxTokens: 0,
+					contextWindow: 0,
+					supportsPromptCache: false,
+					description: `${model.vendor} - ${model.family}`,
+				}
+				return acc
+			},
+			{} as Record<string, ModelInfo>,
+		)
+	}, [vsCodeLmModels])
+
+	// Transform string model ID to { vendor, family } object for storage
+	const valueTransform = useCallback((modelId: string) => {
+		const [vendor, family] = modelId.split("/")
+		return { vendor, family }
+	}, [])
+
+	// Transform stored { vendor, family } object back to display string
+	const displayTransform = useCallback((value: unknown) => {
+		if (!value) return ""
+		const selector = value as { vendor?: string; family?: string }
+		return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : ""
+	}, [])
+
 	return (
 		<>
-			<div>
-				<label className="block font-medium mb-1">{t("settings:providers.vscodeLmModel")}</label>
-				{vsCodeLmModels.length > 0 ? (
-					<Select
-						value={
-							apiConfiguration?.vsCodeLmModelSelector
-								? `${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}`
-								: ""
-						}
-						onValueChange={handleInputChange("vsCodeLmModelSelector", (value) => {
-							const [vendor, family] = value.split("/")
-							return { vendor, family }
-						})}>
-						<SelectTrigger className="w-full">
-							<SelectValue placeholder={t("settings:common.select")} />
-						</SelectTrigger>
-						<SelectContent>
-							{vsCodeLmModels.map((model) => (
-								<SelectItem
-									key={`${model.vendor}/${model.family}`}
-									value={`${model.vendor}/${model.family}`}>
-									{`${model.vendor} - ${model.family}`}
-								</SelectItem>
-							))}
-						</SelectContent>
-					</Select>
-				) : (
+			{vsCodeLmModels.length > 0 ? (
+				<ModelPicker
+					apiConfiguration={apiConfiguration}
+					setApiConfigurationField={setApiConfigurationField}
+					defaultModelId=""
+					models={modelsRecord}
+					modelIdKey="vsCodeLmModelSelector"
+					serviceName="VS Code LM"
+					serviceUrl="https://code.visualstudio.com/api/extension-guides/language-model"
+					valueTransform={valueTransform}
+					displayTransform={displayTransform}
+					hidePricing
+				/>
+			) : (
+				<div>
+					<label className="block font-medium mb-1">{t("settings:providers.vscodeLmModel")}</label>
 					<div className="text-sm text-vscode-descriptionForeground">
 						{t("settings:providers.vscodeLmDescription")}
 					</div>
-				)}
-			</div>
+				</div>
+			)}
 			<div className="text-sm text-vscode-errorForeground">{t("settings:providers.vscodeLmWarning")}</div>
 		</>
 	)

+ 200 - 0
webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts

@@ -0,0 +1,200 @@
+import {
+	PROVIDER_SERVICE_CONFIG,
+	PROVIDER_DEFAULT_MODEL_IDS,
+	getProviderServiceConfig,
+	getDefaultModelIdForProvider,
+	getStaticModelsForProvider,
+	isStaticModelProvider,
+	PROVIDERS_WITH_CUSTOM_MODEL_UI,
+	shouldUseGenericModelPicker,
+} from "../providerModelConfig"
+
+describe("providerModelConfig", () => {
+	describe("PROVIDER_SERVICE_CONFIG", () => {
+		it("contains service config for anthropic", () => {
+			expect(PROVIDER_SERVICE_CONFIG.anthropic).toEqual({
+				serviceName: "Anthropic",
+				serviceUrl: "https://console.anthropic.com",
+			})
+		})
+
+		it("contains service config for bedrock", () => {
+			expect(PROVIDER_SERVICE_CONFIG.bedrock).toEqual({
+				serviceName: "Amazon Bedrock",
+				serviceUrl: "https://aws.amazon.com/bedrock",
+			})
+		})
+
+		it("contains service config for ollama", () => {
+			expect(PROVIDER_SERVICE_CONFIG.ollama).toEqual({
+				serviceName: "Ollama",
+				serviceUrl: "https://ollama.ai",
+			})
+		})
+
+		it("contains service config for lmstudio", () => {
+			expect(PROVIDER_SERVICE_CONFIG.lmstudio).toEqual({
+				serviceName: "LM Studio",
+				serviceUrl: "https://lmstudio.ai/docs",
+			})
+		})
+
+		it("contains service config for vscode-lm", () => {
+			expect(PROVIDER_SERVICE_CONFIG["vscode-lm"]).toEqual({
+				serviceName: "VS Code LM",
+				serviceUrl: "https://code.visualstudio.com/api/extension-guides/language-model",
+			})
+		})
+	})
+
+	describe("getProviderServiceConfig", () => {
+		it("returns correct config for known provider", () => {
+			const config = getProviderServiceConfig("gemini")
+			expect(config.serviceName).toBe("Google Gemini")
+			expect(config.serviceUrl).toBe("https://ai.google.dev")
+		})
+
+		it("returns fallback config for unknown provider", () => {
+			const config = getProviderServiceConfig("unknown-provider" as any)
+			expect(config.serviceName).toBe("unknown-provider")
+			expect(config.serviceUrl).toBe("")
+		})
+	})
+
+	describe("PROVIDER_DEFAULT_MODEL_IDS", () => {
+		it("contains default model IDs for static providers", () => {
+			expect(PROVIDER_DEFAULT_MODEL_IDS.anthropic).toBeDefined()
+			expect(PROVIDER_DEFAULT_MODEL_IDS.bedrock).toBeDefined()
+			expect(PROVIDER_DEFAULT_MODEL_IDS.gemini).toBeDefined()
+			expect(PROVIDER_DEFAULT_MODEL_IDS["openai-native"]).toBeDefined()
+		})
+	})
+
+	describe("getDefaultModelIdForProvider", () => {
+		it("returns default model ID for known provider", () => {
+			const defaultId = getDefaultModelIdForProvider("anthropic")
+			expect(defaultId).toBeDefined()
+			expect(typeof defaultId).toBe("string")
+			expect(defaultId.length).toBeGreaterThan(0)
+		})
+
+		it("returns empty string for unknown provider", () => {
+			const defaultId = getDefaultModelIdForProvider("unknown" as any)
+			expect(defaultId).toBe("")
+		})
+
+		it("returns international default for Z.ai without apiConfiguration", () => {
+			const defaultId = getDefaultModelIdForProvider("zai")
+			expect(defaultId).toBeDefined()
+			expect(typeof defaultId).toBe("string")
+			expect(defaultId.length).toBeGreaterThan(0)
+		})
+
+		it("returns mainland default for Z.ai with china_coding entrypoint", () => {
+			const defaultId = getDefaultModelIdForProvider("zai", {
+				apiProvider: "zai",
+				zaiApiLine: "china_coding",
+			})
+			expect(defaultId).toBeDefined()
+			expect(typeof defaultId).toBe("string")
+			// Mainland model IDs should contain 'mainland' or be different from international
+			expect(defaultId.length).toBeGreaterThan(0)
+		})
+
+		it("returns international default for Z.ai with international_coding entrypoint", () => {
+			const defaultId = getDefaultModelIdForProvider("zai", {
+				apiProvider: "zai",
+				zaiApiLine: "international_coding",
+			})
+			expect(defaultId).toBeDefined()
+			expect(typeof defaultId).toBe("string")
+			expect(defaultId.length).toBeGreaterThan(0)
+		})
+
+		it("uses mainland or international defaults based on zaiApiLine setting", () => {
+			// Verify the function correctly routes to appropriate defaults
+			const chinaDefault = getDefaultModelIdForProvider("zai", {
+				apiProvider: "zai",
+				zaiApiLine: "china_coding",
+			})
+			const internationalDefault = getDefaultModelIdForProvider("zai", {
+				apiProvider: "zai",
+				zaiApiLine: "international_coding",
+			})
+			// Both should return valid model IDs (they may or may not be the same)
+			expect(chinaDefault).toBeDefined()
+			expect(internationalDefault).toBeDefined()
+			expect(chinaDefault.length).toBeGreaterThan(0)
+			expect(internationalDefault.length).toBeGreaterThan(0)
+		})
+	})
+
+	describe("getStaticModelsForProvider", () => {
+		it("returns models for anthropic provider", () => {
+			const models = getStaticModelsForProvider("anthropic")
+			expect(Object.keys(models).length).toBeGreaterThan(0)
+		})
+
+		it("adds custom-arn option for bedrock provider", () => {
+			const models = getStaticModelsForProvider("bedrock", "Use Custom ARN")
+			expect(models["custom-arn"]).toBeDefined()
+			expect(models["custom-arn"].description).toBe("Use Custom ARN")
+		})
+
+		it("returns empty object for providers without static models", () => {
+			const models = getStaticModelsForProvider("openrouter")
+			expect(Object.keys(models).length).toBe(0)
+		})
+	})
+
+	describe("isStaticModelProvider", () => {
+		it("returns true for providers with static models", () => {
+			expect(isStaticModelProvider("anthropic")).toBe(true)
+			expect(isStaticModelProvider("bedrock")).toBe(true)
+			expect(isStaticModelProvider("gemini")).toBe(true)
+			expect(isStaticModelProvider("openai-native")).toBe(true)
+		})
+
+		it("returns false for providers without static models", () => {
+			expect(isStaticModelProvider("openrouter")).toBe(false)
+			expect(isStaticModelProvider("ollama")).toBe(false)
+			expect(isStaticModelProvider("lmstudio")).toBe(false)
+		})
+	})
+
+	describe("PROVIDERS_WITH_CUSTOM_MODEL_UI", () => {
+		it("includes providers that have their own model selection UI", () => {
+			expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("openrouter")
+			expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("ollama")
+			expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("lmstudio")
+			expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("vscode-lm")
+			expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("claude-code")
+		})
+
+		it("does not include static providers using generic picker", () => {
+			expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).not.toContain("anthropic")
+			expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).not.toContain("gemini")
+			expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).not.toContain("bedrock")
+		})
+	})
+
+	describe("shouldUseGenericModelPicker", () => {
+		it("returns true for static providers without custom UI", () => {
+			expect(shouldUseGenericModelPicker("anthropic")).toBe(true)
+			expect(shouldUseGenericModelPicker("bedrock")).toBe(true)
+			expect(shouldUseGenericModelPicker("gemini")).toBe(true)
+			expect(shouldUseGenericModelPicker("deepseek")).toBe(true)
+		})
+
+		it("returns false for providers with custom model UI", () => {
+			expect(shouldUseGenericModelPicker("openrouter")).toBe(false)
+			expect(shouldUseGenericModelPicker("ollama")).toBe(false)
+			expect(shouldUseGenericModelPicker("lmstudio")).toBe(false)
+			expect(shouldUseGenericModelPicker("vscode-lm")).toBe(false)
+		})
+
+		it("returns false for providers without static models", () => {
+			expect(shouldUseGenericModelPicker("openai")).toBe(false)
+		})
+	})
+})

+ 173 - 0
webview-ui/src/components/settings/utils/providerModelConfig.ts

@@ -0,0 +1,173 @@
+import type { ProviderName, ModelInfo, ProviderSettings } from "@roo-code/types"
+import {
+	anthropicDefaultModelId,
+	bedrockDefaultModelId,
+	cerebrasDefaultModelId,
+	deepSeekDefaultModelId,
+	doubaoDefaultModelId,
+	moonshotDefaultModelId,
+	geminiDefaultModelId,
+	mistralDefaultModelId,
+	openAiNativeDefaultModelId,
+	qwenCodeDefaultModelId,
+	vertexDefaultModelId,
+	xaiDefaultModelId,
+	groqDefaultModelId,
+	sambaNovaDefaultModelId,
+	internationalZAiDefaultModelId,
+	mainlandZAiDefaultModelId,
+	fireworksDefaultModelId,
+	featherlessDefaultModelId,
+	minimaxDefaultModelId,
+	basetenDefaultModelId,
+} from "@roo-code/types"
+
+import { MODELS_BY_PROVIDER } from "../constants"
+
+export interface ProviderServiceConfig {
+	serviceName: string
+	serviceUrl: string
+}
+
+export const PROVIDER_SERVICE_CONFIG: Partial<Record<ProviderName, ProviderServiceConfig>> = {
+	anthropic: { serviceName: "Anthropic", serviceUrl: "https://console.anthropic.com" },
+	bedrock: { serviceName: "Amazon Bedrock", serviceUrl: "https://aws.amazon.com/bedrock" },
+	cerebras: { serviceName: "Cerebras", serviceUrl: "https://cerebras.ai" },
+	deepseek: { serviceName: "DeepSeek", serviceUrl: "https://platform.deepseek.com" },
+	doubao: { serviceName: "Doubao", serviceUrl: "https://www.volcengine.com/product/doubao" },
+	moonshot: { serviceName: "Moonshot", serviceUrl: "https://platform.moonshot.cn" },
+	gemini: { serviceName: "Google Gemini", serviceUrl: "https://ai.google.dev" },
+	mistral: { serviceName: "Mistral", serviceUrl: "https://console.mistral.ai" },
+	"openai-native": { serviceName: "OpenAI", serviceUrl: "https://platform.openai.com" },
+	"qwen-code": { serviceName: "Qwen Code", serviceUrl: "https://dashscope.console.aliyun.com" },
+	vertex: { serviceName: "GCP Vertex AI", serviceUrl: "https://console.cloud.google.com/vertex-ai" },
+	xai: { serviceName: "xAI", serviceUrl: "https://x.ai" },
+	groq: { serviceName: "Groq", serviceUrl: "https://console.groq.com" },
+	sambanova: { serviceName: "SambaNova", serviceUrl: "https://sambanova.ai" },
+	zai: { serviceName: "Z.ai", serviceUrl: "https://z.ai" },
+	fireworks: { serviceName: "Fireworks AI", serviceUrl: "https://fireworks.ai" },
+	featherless: { serviceName: "Featherless AI", serviceUrl: "https://featherless.ai" },
+	minimax: { serviceName: "MiniMax", serviceUrl: "https://minimax.chat" },
+	baseten: { serviceName: "Baseten", serviceUrl: "https://baseten.co" },
+	ollama: { serviceName: "Ollama", serviceUrl: "https://ollama.ai" },
+	lmstudio: { serviceName: "LM Studio", serviceUrl: "https://lmstudio.ai/docs" },
+	"vscode-lm": {
+		serviceName: "VS Code LM",
+		serviceUrl: "https://code.visualstudio.com/api/extension-guides/language-model",
+	},
+}
+
+export const PROVIDER_DEFAULT_MODEL_IDS: Partial<Record<ProviderName, string>> = {
+	anthropic: anthropicDefaultModelId,
+	bedrock: bedrockDefaultModelId,
+	cerebras: cerebrasDefaultModelId,
+	deepseek: deepSeekDefaultModelId,
+	doubao: doubaoDefaultModelId,
+	moonshot: moonshotDefaultModelId,
+	gemini: geminiDefaultModelId,
+	mistral: mistralDefaultModelId,
+	"openai-native": openAiNativeDefaultModelId,
+	"qwen-code": qwenCodeDefaultModelId,
+	vertex: vertexDefaultModelId,
+	xai: xaiDefaultModelId,
+	groq: groqDefaultModelId,
+	sambanova: sambaNovaDefaultModelId,
+	zai: internationalZAiDefaultModelId,
+	fireworks: fireworksDefaultModelId,
+	featherless: featherlessDefaultModelId,
+	minimax: minimaxDefaultModelId,
+	baseten: basetenDefaultModelId,
+}
+
+export const getProviderServiceConfig = (provider: ProviderName): ProviderServiceConfig => {
+	return PROVIDER_SERVICE_CONFIG[provider] ?? { serviceName: provider, serviceUrl: "" }
+}
+
+export const getDefaultModelIdForProvider = (provider: ProviderName, apiConfiguration?: ProviderSettings): string => {
+	// Handle Z.ai's China/International entrypoint distinction
+	if (provider === "zai" && apiConfiguration) {
+		return apiConfiguration.zaiApiLine === "china_coding"
+			? mainlandZAiDefaultModelId
+			: internationalZAiDefaultModelId
+	}
+
+	return PROVIDER_DEFAULT_MODEL_IDS[provider] ?? ""
+}
+
+export const getStaticModelsForProvider = (
+	provider: ProviderName,
+	customArnLabel?: string,
+): Record<string, ModelInfo> => {
+	const models = MODELS_BY_PROVIDER[provider] ?? {}
+
+	// Add custom-arn option for Bedrock
+	if (provider === "bedrock") {
+		return {
+			...models,
+			"custom-arn": {
+				maxTokens: 0,
+				contextWindow: 0,
+				supportsPromptCache: false,
+				description: customArnLabel ?? "Use Custom ARN",
+			},
+		}
+	}
+
+	return models
+}
+
+/**
+ * Checks if a provider uses static models from MODELS_BY_PROVIDER
+ */
+export const isStaticModelProvider = (provider: ProviderName): boolean => {
+	return provider in MODELS_BY_PROVIDER
+}
+
+/**
+ * List of providers that have their own custom model selection UI
+ * and should not use the generic ModelPicker in ApiOptions
+ */
+export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [
+	"openrouter",
+	"requesty",
+	"unbound",
+	"deepinfra",
+	"claude-code",
+	"openai", // OpenAI Compatible
+	"litellm",
+	"io-intelligence",
+	"vercel-ai-gateway",
+	"roo",
+	"chutes",
+	"ollama",
+	"lmstudio",
+	"vscode-lm",
+	"huggingface",
+]
+
+/**
+ * Checks if a provider should use the generic ModelPicker
+ */
+export const shouldUseGenericModelPicker = (provider: ProviderName): boolean => {
+	return isStaticModelProvider(provider) && !PROVIDERS_WITH_CUSTOM_MODEL_UI.includes(provider)
+}
+
+/**
+ * Handles provider-specific side effects when a model is changed.
+ * Centralizes provider-specific logic to keep it out of the ApiOptions template.
+ */
+export const handleModelChangeSideEffects = <K extends keyof ProviderSettings>(
+	provider: ProviderName,
+	modelId: string,
+	setApiConfigurationField: (field: K, value: ProviderSettings[K]) => void,
+): void => {
+	// Bedrock: Clear custom ARN if not using custom ARN option
+	if (provider === "bedrock" && modelId !== "custom-arn") {
+		setApiConfigurationField("awsCustomArn" as K, "" as ProviderSettings[K])
+	}
+
+	// All providers: Clear reasoning effort when switching models to allow
+	// the new model's default to take effect. Different models within the
+	// same provider can have different reasoning effort defaults/options.
+	setApiConfigurationField("reasoningEffort" as K, undefined as ProviderSettings[K])
+}