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