mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
44fd975b17
commit
ca34143fa5
6 changed files with 388 additions and 3 deletions
67
packages/types/src/__tests__/all-model-capabilities.test.ts
Normal file
67
packages/types/src/__tests__/all-model-capabilities.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
69
packages/types/src/providers/all-model-capabilities.ts
Normal file
69
packages/types/src/providers/all-model-capabilities.ts
Normal 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),
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue