Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 39 additions & 78 deletions webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ import {
minimaxDefaultModelId,
} from "@roo-code/types"

import {
getProviderServiceConfig,
getDefaultModelIdForProvider,
getStaticModelsForProvider,
shouldUseGenericModelPicker,
} from "./utils/providerModelConfig"

import { vscode } from "@src/utils/vscode"
import { validateApiConfigurationExcludingModelErrors, getModelValidationError } from "@src/utils/validate"
import { useAppTranslation } from "@src/i18n/TranslationContext"
Expand Down Expand Up @@ -102,7 +109,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"
Expand Down Expand Up @@ -171,7 +178,6 @@ const ApiOptions = ({
[customHeaders, apiConfiguration?.openAiHeaders, setApiConfigurationField],
)

const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false)

const handleInputChange = useCallback(
Expand Down Expand Up @@ -270,32 +276,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)
Expand Down Expand Up @@ -767,65 +747,46 @@ const ApiOptions = ({
<Featherless apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}

{/* Skip generic model picker for claude-code since it has its own in ClaudeCode.tsx */}
{selectedProviderModels.length > 0 && selectedProvider !== "claude-code" && (
{/* Generic model picker for providers with static models */}
{shouldUseGenericModelPicker(selectedProvider) && (
<>
<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>
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId={
selectedProvider === "zai"
? apiConfiguration.zaiApiLine === "china_coding"
? mainlandZAiDefaultModelId
: internationalZAiDefaultModelId
: getDefaultModelIdForProvider(selectedProvider)
}
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) => {
// Clear custom ARN if not using custom ARN option (Bedrock)
if (modelId !== "custom-arn" && selectedProvider === "bedrock") {
setApiConfigurationField("awsCustomArn", "")
}

{/* Show error if a deprecated model is selected */}
{selectedModelInfo?.deprecated && (
<ApiErrorMessage errorMessage={t("settings:validation.modelDeprecated")} />
)}
// 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)
}
}}
/>

{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}
/>
)}
</>
)}

Expand Down
42 changes: 37 additions & 5 deletions webview-ui/src/components/settings/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ type ModelIdKey = keyof Pick<
| "ioIntelligenceModelId"
| "vercelAiGatewayModelId"
| "apiModelId"
| "ollamaModelId"
| "lmStudioModelId"
| "lmStudioDraftModelId"
| "vsCodeLmModelSelector"
>

interface ModelPickerProps {
Expand All @@ -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 = ({
Expand All @@ -69,6 +81,10 @@ export const ModelPicker = ({
errorMessage,
simplifySettings,
hidePricing,
label,
valueTransform,
displayTransform,
onModelChange,
}: ModelPickerProps) => {
const { t } = useAppTranslation()

Expand All @@ -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)

Expand Down Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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
Expand All @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading