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 <roomote@roocode.com>
This commit is contained in:
Hannes Rudolph 2026-01-20 16:22:49 -07:00 committed by GitHub
parent a060915d18
commit e356d058e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 574 additions and 283 deletions

View file

@ -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)
{/* 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)
}
/>
// 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}
/>
)}
</>
)}
{selectedProvider === "bedrock" && selectedModelId === "custom-arn" && (
<BedrockCustomArn
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
/>
)}
</>
)}
{!fromWelcomeView && (
<ThinkingBudget

View file

@ -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>

View file

@ -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", () => {

View file

@ -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">

View file

@ -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 {

View file

@ -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>
</>
)

View file

@ -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)
})
})
})

View file

@ -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])
}