From ca34143fa54c45aeaac19e58419f959583e4d53a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 9 Mar 2026 18:49:21 +0000 Subject: [PATCH] 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 --- .../__tests__/all-model-capabilities.test.ts | 67 ++++++++++ .../src/providers/all-model-capabilities.ts | 69 ++++++++++ packages/types/src/providers/index.ts | 1 + .../settings/providers/OpenAICompatible.tsx | 126 +++++++++++++++++- .../__tests__/OpenAICompatible.spec.tsx | 120 ++++++++++++++++- webview-ui/src/i18n/locales/en/settings.json | 8 ++ 6 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 packages/types/src/__tests__/all-model-capabilities.test.ts create mode 100644 packages/types/src/providers/all-model-capabilities.ts diff --git a/packages/types/src/__tests__/all-model-capabilities.test.ts b/packages/types/src/__tests__/all-model-capabilities.test.ts new file mode 100644 index 0000000000..c80cd95488 --- /dev/null +++ b/packages/types/src/__tests__/all-model-capabilities.test.ts @@ -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) + } + }) +}) diff --git a/packages/types/src/providers/all-model-capabilities.ts b/packages/types/src/providers/all-model-capabilities.ts new file mode 100644 index 0000000000..78bde66ebb --- /dev/null +++ b/packages/types/src/providers/all-model-capabilities.ts @@ -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): 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), +] diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 6bb959c705..85311904ac 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -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" diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index 0524932c5f..9a50aace57 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -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(null) const [openAiModels, setOpenAiModels] = useState | null>(null) + // Group presets by provider for organized display + const groupedPresets = useMemo(() => { + const groups: Record = {} + 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 = ({ )}
+
+ +
+ {t("settings:providers.customModel.capabilityPreset.description")} +
+ + + + + + + + + + {t("settings:providers.customModel.capabilityPreset.noResults")} + + + handlePresetSelect("custom")}> + + {t("settings:providers.customModel.capabilityPreset.custom")} + + + {Object.entries(groupedPresets).map(([provider, presets]) => ( + + {presets.map((preset) => { + const presetKey = `${preset.provider}/${preset.modelId}` + return ( + handlePresetSelect(presetKey)}> + + {preset.modelId} + {preset.info.description && ( + + {preset.info.contextWindow + ? `${Math.round(preset.info.contextWindow / 1000)}K ctx` + : ""} + + )} + + ) + })} + + ))} + + + + +
+
{t("settings:providers.customModel.capabilities")}
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx index aba81ec219..7422cbd3c0 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx @@ -63,8 +63,40 @@ vi.mock("@src/i18n/TranslationContext", () => ({ // Mock the UI components vi.mock("@src/components/ui", () => ({ - Button: ({ children, onClick }: any) => , + Button: ({ children, onClick, ...props }: any) => ( + + ), StandardTooltip: ({ children, content }: any) =>
{children}
, + Command: ({ children }: any) =>
{children}
, + CommandEmpty: ({ children }: any) =>
{children}
, + CommandGroup: ({ children, heading }: any) =>
{children}
, + CommandInput: ({ placeholder }: any) => , + CommandItem: ({ children, onSelect, value }: any) => ( +
+ {children} +
+ ), + CommandList: ({ children }: any) =>
{children}
, + Popover: ({ children, open }: any) => ( +
+ {children} +
+ ), + PopoverContent: ({ children }: any) =>
{children}
, + PopoverTrigger: ({ children }: any) =>
{children}
, +})) + +// Mock lucide-react icons +vi.mock("lucide-react", () => ({ + ChevronsUpDown: () => , + Check: () => , +})) + +// 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 = {} + + render( + , + ) + + // 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 = {} + + render( + , + ) + + // 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 = {} + + render( + , + ) + + // 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 = {} + + render( + , + ) + + // 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), + }), + ) + }) +}) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 3b2497aaee..a0dcc1a9ce 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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",