feat: add model capability preset picker for OpenAI Compatible provider

Adds a searchable dropdown to the OpenAI Compatible provider settings
that lets users select from all known model capabilities across every
provider Roo supports (Anthropic, OpenAI, DeepSeek, Gemini, MiniMax,
Mistral, Moonshot/Kimi, Qwen, SambaNova, xAI, ZAi/GLM).

When a preset is selected, the model capability fields (context window,
max tokens, image support, prompt caching, pricing, etc.) are
automatically populated. Users can still choose "Custom" to configure
everything manually as before.

Changes:
- packages/types: new all-model-capabilities.ts aggregating presets
- webview-ui: preset picker dropdown in OpenAICompatible.tsx
- i18n: English translation keys for the new UI
- Tests for both the preset data and the UI component

Addresses #11674
This commit is contained in:
Roo Code 2026-03-09 18:49:21 +00:00
parent 44fd975b17
commit ca34143fa5
6 changed files with 388 additions and 3 deletions

View file

@ -0,0 +1,67 @@
import { modelCapabilityPresets } from "../providers/all-model-capabilities"
describe("modelCapabilityPresets", () => {
it("should be a non-empty array", () => {
expect(Array.isArray(modelCapabilityPresets)).toBe(true)
expect(modelCapabilityPresets.length).toBeGreaterThan(0)
})
it("every preset should have a provider, modelId, and info with required fields", () => {
for (const preset of modelCapabilityPresets) {
expect(typeof preset.provider).toBe("string")
expect(preset.provider.length).toBeGreaterThan(0)
expect(typeof preset.modelId).toBe("string")
expect(preset.modelId.length).toBeGreaterThan(0)
expect(preset.info).toBeDefined()
expect(typeof preset.info.contextWindow).toBe("number")
expect(preset.info.contextWindow).toBeGreaterThan(0)
// supportsPromptCache is a required field in ModelInfo
expect(typeof preset.info.supportsPromptCache).toBe("boolean")
}
})
it("should include models from multiple providers", () => {
const providers = new Set(modelCapabilityPresets.map((p) => p.provider))
expect(providers.size).toBeGreaterThan(5)
})
it("should include well-known models", () => {
const modelIds = modelCapabilityPresets.map((p) => p.modelId)
// Check for some well-known models
expect(modelIds.some((id) => id.includes("claude"))).toBe(true)
expect(modelIds.some((id) => id.includes("gpt"))).toBe(true)
expect(modelIds.some((id) => id.includes("deepseek"))).toBe(true)
expect(modelIds.some((id) => id.includes("gemini"))).toBe(true)
})
it("should have unique provider/modelId combinations", () => {
const keys = modelCapabilityPresets.map((p) => `${p.provider}/${p.modelId}`)
const uniqueKeys = new Set(keys)
expect(uniqueKeys.size).toBe(keys.length)
})
it("each preset should include known providers", () => {
const knownProviders = [
"Anthropic",
"OpenAI",
"DeepSeek",
"Gemini",
"MiniMax",
"Mistral",
"Moonshot (Kimi)",
"Qwen",
"SambaNova",
"xAI",
"ZAi (GLM)",
]
const providers = new Set(modelCapabilityPresets.map((p) => p.provider))
for (const known of knownProviders) {
expect(providers.has(known)).toBe(true)
}
})
})

View file

@ -0,0 +1,69 @@
/**
* Aggregated model capabilities from all providers.
*
* This map is used by the OpenAI Compatible provider to let users select
* a known model's capabilities (context window, max tokens, image support,
* prompt caching, etc.) so Roo can communicate optimally with local or
* third-party endpoints that serve these models.
*/
import type { ModelInfo } from "../model.js"
import { anthropicModels } from "./anthropic.js"
import { deepSeekModels } from "./deepseek.js"
import { geminiModels } from "./gemini.js"
import { minimaxModels } from "./minimax.js"
import { mistralModels } from "./mistral.js"
import { moonshotModels } from "./moonshot.js"
import { openAiNativeModels } from "./openai.js"
import { sambaNovaModels } from "./sambanova.js"
import { xaiModels } from "./xai.js"
import { internationalZAiModels } from "./zai.js"
import { qwenCodeModels } from "./qwen-code.js"
/**
* A single entry in the capability presets list.
*/
export interface ModelCapabilityPreset {
/** The provider this model originally belongs to */
provider: string
/** The model ID as known by its native provider */
modelId: string
/** The model's capability info */
info: ModelInfo
}
/**
* Helper to build preset entries from a provider's model record.
*/
function buildPresets(provider: string, models: Record<string, ModelInfo>): ModelCapabilityPreset[] {
return Object.entries(models).map(([modelId, info]) => ({
provider,
modelId,
info,
}))
}
/**
* All known model capability presets, aggregated from every provider.
*
* We intentionally exclude cloud-only routing providers (OpenRouter, Requesty,
* LiteLLM, Roo, Unbound, Vercel AI Gateway) and platform-locked providers
* (Bedrock, Vertex, VSCode LM, OpenAI Codex, Baseten, Fireworks) since those
* models are either duplicates of the originals or have platform-specific
* model IDs that don't map to local inference.
*
* The user can always choose "Custom" and configure capabilities manually.
*/
export const modelCapabilityPresets: ModelCapabilityPreset[] = [
...buildPresets("Anthropic", anthropicModels),
...buildPresets("OpenAI", openAiNativeModels),
...buildPresets("DeepSeek", deepSeekModels),
...buildPresets("Gemini", geminiModels),
...buildPresets("MiniMax", minimaxModels),
...buildPresets("Mistral", mistralModels),
...buildPresets("Moonshot (Kimi)", moonshotModels),
...buildPresets("Qwen", qwenCodeModels),
...buildPresets("SambaNova", sambaNovaModels),
...buildPresets("xAI", xaiModels),
...buildPresets("ZAi (GLM)", internationalZAiModels),
]

View file

@ -24,6 +24,7 @@ export * from "./xai.js"
export * from "./vercel-ai-gateway.js"
export * from "./zai.js"
export * from "./minimax.js"
export * from "./all-model-capabilities.js"
import { anthropicDefaultModelId } from "./anthropic.js"
import { basetenDefaultModelId } from "./baseten.js"

View file

@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from "react"
import { useState, useCallback, useEffect, useMemo } from "react"
import { useEvent } from "react-use"
import { Checkbox } from "vscrui"
import { ChevronsUpDown, Check } from "lucide-react"
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import {
@ -11,10 +12,24 @@ import {
type ExtensionMessage,
azureOpenAiDefaultApiVersion,
openAiModelInfoSaneDefaults,
modelCapabilityPresets,
} from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { Button, StandardTooltip } from "@src/components/ui"
import {
Button,
StandardTooltip,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
} from "@src/components/ui"
import { cn } from "@src/lib/utils"
import { convertHeadersToObject } from "../utils/headers"
import { inputEventTransform, noTransform } from "../transforms"
@ -44,9 +59,40 @@ export const OpenAICompatible = ({
const { t } = useAppTranslation()
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
const [presetPickerOpen, setPresetPickerOpen] = useState(false)
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null)
const [openAiModels, setOpenAiModels] = useState<Record<string, ModelInfo> | null>(null)
// Group presets by provider for organized display
const groupedPresets = useMemo(() => {
const groups: Record<string, typeof modelCapabilityPresets> = {}
for (const preset of modelCapabilityPresets) {
if (!groups[preset.provider]) {
groups[preset.provider] = []
}
groups[preset.provider].push(preset)
}
return groups
}, [])
const handlePresetSelect = useCallback(
(presetKey: string) => {
if (presetKey === "custom") {
setSelectedPresetId(null)
setApiConfigurationField("openAiCustomModelInfo", openAiModelInfoSaneDefaults)
} else {
const preset = modelCapabilityPresets.find((p) => `${p.provider}/${p.modelId}` === presetKey)
if (preset) {
setSelectedPresetId(presetKey)
setApiConfigurationField("openAiCustomModelInfo", { ...preset.info })
}
}
setPresetPickerOpen(false)
},
[setApiConfigurationField],
)
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
const headers = apiConfiguration?.openAiHeaders || {}
return Object.entries(headers)
@ -278,6 +324,82 @@ export const OpenAICompatible = ({
)}
</div>
<div className="flex flex-col gap-3">
<div>
<label className="block font-medium mb-1">
{t("settings:providers.customModel.capabilityPreset.label")}
</label>
<div className="text-sm text-vscode-descriptionForeground mb-2">
{t("settings:providers.customModel.capabilityPreset.description")}
</div>
<Popover open={presetPickerOpen} onOpenChange={setPresetPickerOpen}>
<PopoverTrigger asChild>
<Button
variant="combobox"
role="combobox"
aria-expanded={presetPickerOpen}
className="w-full justify-between">
<span className="truncate">
{selectedPresetId ?? t("settings:providers.customModel.capabilityPreset.custom")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput
placeholder={t("settings:providers.customModel.capabilityPreset.searchPlaceholder")}
/>
<CommandList>
<CommandEmpty>
{t("settings:providers.customModel.capabilityPreset.noResults")}
</CommandEmpty>
<CommandGroup heading={t("settings:providers.customModel.capabilityPreset.custom")}>
<CommandItem value="custom" onSelect={() => handlePresetSelect("custom")}>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedPresetId === null ? "opacity-100" : "opacity-0",
)}
/>
{t("settings:providers.customModel.capabilityPreset.custom")}
</CommandItem>
</CommandGroup>
{Object.entries(groupedPresets).map(([provider, presets]) => (
<CommandGroup key={provider} heading={provider}>
{presets.map((preset) => {
const presetKey = `${preset.provider}/${preset.modelId}`
return (
<CommandItem
key={presetKey}
value={presetKey}
onSelect={() => handlePresetSelect(presetKey)}>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedPresetId === presetKey
? "opacity-100"
: "opacity-0",
)}
/>
{preset.modelId}
{preset.info.description && (
<span className="ml-2 text-xs text-vscode-descriptionForeground truncate">
{preset.info.contextWindow
? `${Math.round(preset.info.contextWindow / 1000)}K ctx`
: ""}
</span>
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="text-sm text-vscode-descriptionForeground whitespace-pre-line">
{t("settings:providers.customModel.capabilities")}
</div>

View file

@ -63,8 +63,40 @@ vi.mock("@src/i18n/TranslationContext", () => ({
// Mock the UI components
vi.mock("@src/components/ui", () => ({
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
Command: ({ children }: any) => <div data-testid="command">{children}</div>,
CommandEmpty: ({ children }: any) => <div data-testid="command-empty">{children}</div>,
CommandGroup: ({ children, heading }: any) => <div data-testid={`command-group-${heading}`}>{children}</div>,
CommandInput: ({ placeholder }: any) => <input data-testid="command-input" placeholder={placeholder} />,
CommandItem: ({ children, onSelect, value }: any) => (
<div data-testid={`command-item-${value}`} onClick={onSelect}>
{children}
</div>
),
CommandList: ({ children }: any) => <div data-testid="command-list">{children}</div>,
Popover: ({ children, open }: any) => (
<div data-testid="popover" data-open={open}>
{children}
</div>
),
PopoverContent: ({ children }: any) => <div data-testid="popover-content">{children}</div>,
PopoverTrigger: ({ children }: any) => <div data-testid="popover-trigger">{children}</div>,
}))
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
ChevronsUpDown: () => <span data-testid="chevrons-icon" />,
Check: () => <span data-testid="check-icon" />,
}))
// Mock cn utility
vi.mock("@src/lib/utils", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
}))
// Mock other components
@ -313,3 +345,89 @@ describe("OpenAICompatible Component - includeMaxTokens checkbox", () => {
})
})
})
describe("OpenAICompatible Component - Model Capability Presets", () => {
const mockSetApiConfigurationField = vi.fn()
const mockOrganizationAllowList = {
allowAll: true,
providers: {},
}
beforeEach(() => {
vi.clearAllMocks()
})
it("should render the model capability preset picker", () => {
const apiConfiguration: Partial<ProviderSettings> = {}
render(
<OpenAICompatible
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
organizationAllowList={mockOrganizationAllowList}
/>,
)
// Check that the preset label is rendered
expect(screen.getByText("settings:providers.customModel.capabilityPreset.label")).toBeInTheDocument()
// Check that the description is rendered
expect(screen.getByText("settings:providers.customModel.capabilityPreset.description")).toBeInTheDocument()
})
it("should render the popover trigger button with Custom text by default", () => {
const apiConfiguration: Partial<ProviderSettings> = {}
render(
<OpenAICompatible
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
organizationAllowList={mockOrganizationAllowList}
/>,
)
// Should show the custom option text (appears in button, group heading, and item)
const customTexts = screen.getAllByText("settings:providers.customModel.capabilityPreset.custom")
expect(customTexts.length).toBeGreaterThanOrEqual(1)
})
it("should render command items for model presets grouped by provider", () => {
const apiConfiguration: Partial<ProviderSettings> = {}
render(
<OpenAICompatible
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
organizationAllowList={mockOrganizationAllowList}
/>,
)
// Check that provider groups are rendered (via mocked CommandGroup with heading)
expect(screen.getByTestId("command-group-Anthropic")).toBeInTheDocument()
expect(screen.getByTestId("command-group-OpenAI")).toBeInTheDocument()
expect(screen.getByTestId("command-group-DeepSeek")).toBeInTheDocument()
})
it("should call setApiConfigurationField with preset info when a model is selected", () => {
const apiConfiguration: Partial<ProviderSettings> = {}
render(
<OpenAICompatible
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
organizationAllowList={mockOrganizationAllowList}
/>,
)
// Click on the "custom" item to reset to defaults
const customItem = screen.getByTestId("command-item-custom")
fireEvent.click(customItem)
expect(mockSetApiConfigurationField).toHaveBeenCalledWith(
"openAiCustomModelInfo",
expect.objectContaining({
contextWindow: expect.any(Number),
}),
)
})
})

View file

@ -543,6 +543,14 @@
}
},
"customModel": {
"capabilityPreset": {
"label": "Model Capability Preset",
"description": "Select a known model to automatically configure capabilities (context window, max tokens, image support, etc.). Choose \"Custom\" to configure manually.",
"custom": "Custom (configure manually)",
"searchPlaceholder": "Search models...",
"noResults": "No matching models found.",
"applied": "Applied capabilities from {{model}}"
},
"capabilities": "Configure the capabilities and pricing for your custom OpenAI-compatible model. Be careful when specifying the model capabilities, as they can affect how Roo Code performs.",
"maxTokens": {
"label": "Max Output Tokens",