From 9c3c986fc862f27a55aeef8378d16eb7326bcfd9 Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 5 May 2025 15:21:44 -0700 Subject: [PATCH 1/4] Organize provider settings into separate components --- .changeset/five-pigs-talk.md | 5 + .../components/settings/providers/Bedrock.tsx | 126 ++++ .../settings/providers/LMStudio.tsx | 152 +++++ .../settings/providers/OpenAICompatible.tsx | 578 ++++++++++++++++++ .../settings/providers/OpenRouter.tsx | 109 ++++ .../providers/OpenRouterBalanceDisplay.tsx | 19 + .../src/components/settings/transforms.ts | 3 + 7 files changed, 992 insertions(+) create mode 100644 .changeset/five-pigs-talk.md create mode 100644 webview-ui/src/components/settings/providers/Bedrock.tsx create mode 100644 webview-ui/src/components/settings/providers/LMStudio.tsx create mode 100644 webview-ui/src/components/settings/providers/OpenAICompatible.tsx create mode 100644 webview-ui/src/components/settings/providers/OpenRouter.tsx create mode 100644 webview-ui/src/components/settings/providers/OpenRouterBalanceDisplay.tsx create mode 100644 webview-ui/src/components/settings/transforms.ts diff --git a/.changeset/five-pigs-talk.md b/.changeset/five-pigs-talk.md new file mode 100644 index 0000000000..8ab089ce40 --- /dev/null +++ b/.changeset/five-pigs-talk.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Organize provider settings into separate components diff --git a/webview-ui/src/components/settings/providers/Bedrock.tsx b/webview-ui/src/components/settings/providers/Bedrock.tsx new file mode 100644 index 0000000000..fc69e74dde --- /dev/null +++ b/webview-ui/src/components/settings/providers/Bedrock.tsx @@ -0,0 +1,126 @@ +import { useCallback } from "react" +import { Checkbox } from "vscrui" +import { VSCodeTextField, VSCodeRadio, VSCodeRadioGroup } from "@vscode/webview-ui-toolkit/react" + +import { ApiConfiguration, ModelInfo } from "@roo/shared/api" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" + +import { AWS_REGIONS } from "../constants" +import { inputEventTransform, noTransform } from "../transforms" + +type BedrockProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void + selectedModelInfo?: ModelInfo +} + +export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedModelInfo }: BedrockProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + (e.target as HTMLInputElement).value === "profile", + )}> + {t("settings:providers.awsCredentials")} + {t("settings:providers.awsProfile")} + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {apiConfiguration?.awsUseProfile ? ( + + + + ) : ( + <> + + + + + + + + + + + )} +
+ + +
+ + {t("settings:providers.awsCrossRegion")} + + {selectedModelInfo?.supportsPromptCache && ( + +
+ {t("settings:providers.enablePromptCaching")} + +
+
+ )} +
+
+ {t("settings:providers.cacheUsageNote")} +
+
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/LMStudio.tsx b/webview-ui/src/components/settings/providers/LMStudio.tsx new file mode 100644 index 0000000000..0979ec9242 --- /dev/null +++ b/webview-ui/src/components/settings/providers/LMStudio.tsx @@ -0,0 +1,152 @@ +import { useCallback, useState } 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 { ApiConfiguration } from "@roo/shared/api" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { ExtensionMessage } from "@roo/shared/ExtensionMessage" + +import { inputEventTransform } from "../transforms" + +type LMStudioProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void +} + +export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudioProps) => { + const { t } = useAppTranslation() + + const [lmStudioModels, setLmStudioModels] = useState([]) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + const onMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + + switch (message.type) { + case "lmStudioModels": + { + const newModels = message.lmStudioModels ?? [] + setLmStudioModels(newModels) + } + break + } + }, []) + + useEvent("message", onMessage) + + return ( + <> + + + + + + + {lmStudioModels.length > 0 && ( + + {lmStudioModels.map((model) => ( + + {model} + + ))} + + )} + { + setApiConfigurationField("lmStudioSpeculativeDecodingEnabled", checked) + }}> + {t("settings:providers.lmStudio.speculativeDecoding")} + + {apiConfiguration?.lmStudioSpeculativeDecodingEnabled && ( + <> +
+ + + +
+ {t("settings:providers.lmStudio.draftModelDesc")} +
+
+ {lmStudioModels.length > 0 && ( + <> +
{t("settings:providers.lmStudio.selectDraftModel")}
+ + {lmStudioModels.map((model) => ( + + {model} + + ))} + + {lmStudioModels.length === 0 && ( +
+ {t("settings:providers.lmStudio.noModelsFound")} +
+ )} + + )} + + )} +
+ , + b: , + span: ( + + Note: + + ), + }} + /> +
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx new file mode 100644 index 0000000000..9d55d57f8c --- /dev/null +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -0,0 +1,578 @@ +import { useState, useCallback } from "react" +import { useEvent } from "react-use" +import { Checkbox } from "vscrui" +import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { ModelInfo, ReasoningEffort as ReasoningEffortType } from "@roo/schemas" +import { ApiConfiguration, azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults } from "@roo/shared/api" +import { ExtensionMessage } from "@roo/shared/ExtensionMessage" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Button } from "@src/components/ui" + +import { inputEventTransform, noTransform } from "../transforms" +import { ModelPicker } from "../ModelPicker" +import { R1FormatSetting } from "../R1FormatSetting" +import { ReasoningEffort } from "../ReasoningEffort" + +type OpenAICompatibleProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void +} + +export const OpenAICompatible = ({ apiConfiguration, setApiConfigurationField }: OpenAICompatibleProps) => { + const { t } = useAppTranslation() + + const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) + const [openAiLegacyFormatSelected, setOpenAiLegacyFormatSelected] = useState(!!apiConfiguration?.openAiLegacyFormat) + + const [openAiModels, setOpenAiModels] = useState | null>(null) + + const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { + const headers = apiConfiguration?.openAiHeaders || {} + return Object.entries(headers) + }) + + const handleAddCustomHeader = useCallback(() => { + // Only update the local state to show the new row in the UI. + setCustomHeaders((prev) => [...prev, ["", ""]]) + // Do not update the main configuration yet, wait for user input. + }, []) + + const handleUpdateHeaderKey = useCallback((index: number, newKey: string) => { + setCustomHeaders((prev) => { + const updated = [...prev] + + if (updated[index]) { + updated[index] = [newKey, updated[index][1]] + } + + return updated + }) + }, []) + + const handleUpdateHeaderValue = useCallback((index: number, newValue: string) => { + setCustomHeaders((prev) => { + const updated = [...prev] + + if (updated[index]) { + updated[index] = [updated[index][0], newValue] + } + + return updated + }) + }, []) + + const handleRemoveCustomHeader = useCallback((index: number) => { + setCustomHeaders((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + const onMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + + switch (message.type) { + case "openAiModels": { + const updatedModels = message.openAiModels ?? [] + setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults]))) + break + } + } + }, []) + + useEvent("message", onMessage) + + return ( + <> + + + + + + + + +
+ { + setOpenAiLegacyFormatSelected(checked) + setApiConfigurationField("openAiLegacyFormat", checked) + }}> + {t("settings:providers.useLegacyFormat")} + +
+ + {t("settings:modelInfo.enableStreaming")} + + + {t("settings:modelInfo.useAzure")} + +
+ { + setAzureApiVersionSelected(checked) + + if (!checked) { + setApiConfigurationField("azureApiVersion", "") + } + }}> + {t("settings:modelInfo.azureApiVersion")} + + {azureApiVersionSelected && ( + + )} +
+ + {/* Custom Headers UI */} +
+
+ + + + +
+ {!customHeaders.length ? ( +
+ {t("settings:providers.noCustomHeaders")} +
+ ) : ( + customHeaders.map(([key, value], index) => ( +
+ handleUpdateHeaderKey(index, e.target.value)} + /> + handleUpdateHeaderValue(index, e.target.value)} + /> + handleRemoveCustomHeader(index)}> + + +
+ )) + )} +
+ +
+ { + setApiConfigurationField("enableReasoningEffort", checked) + + if (!checked) { + const { reasoningEffort: _, ...openAiCustomModelInfo } = + apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults + + setApiConfigurationField("openAiCustomModelInfo", openAiCustomModelInfo) + } + }}> + {t("settings:providers.setReasoningLevel")} + + {!!apiConfiguration.enableReasoningEffort && ( + { + if (field === "reasoningEffort") { + const openAiCustomModelInfo = + apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults + + setApiConfigurationField("openAiCustomModelInfo", { + ...openAiCustomModelInfo, + reasoningEffort: value as ReasoningEffortType, + }) + } + }} + /> + )} +
+
+
+ {t("settings:providers.customModel.capabilities")} +
+ +
+ { + const value = apiConfiguration?.openAiCustomModelInfo?.maxTokens + + if (!value) { + return "var(--vscode-input-border)" + } + + return value > 0 ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)" + })(), + }} + title={t("settings:providers.customModel.maxTokens.description")} + onInput={handleInputChange("openAiCustomModelInfo", (e) => { + const value = parseInt((e.target as HTMLInputElement).value) + + return { + ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), + maxTokens: isNaN(value) ? undefined : value, + } + })} + placeholder={t("settings:placeholders.numbers.maxTokens")} + className="w-full"> + + +
+ {t("settings:providers.customModel.maxTokens.description")} +
+
+ +
+ { + const value = apiConfiguration?.openAiCustomModelInfo?.contextWindow + + if (!value) { + return "var(--vscode-input-border)" + } + + return value > 0 ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)" + })(), + }} + title={t("settings:providers.customModel.contextWindow.description")} + onInput={handleInputChange("openAiCustomModelInfo", (e) => { + const value = (e.target as HTMLInputElement).value + const parsed = parseInt(value) + + return { + ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), + contextWindow: isNaN(parsed) ? openAiModelInfoSaneDefaults.contextWindow : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.contextWindow")} + className="w-full"> + + +
+ {t("settings:providers.customModel.contextWindow.description")} +
+
+ +
+
+ { + return { + ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), + supportsImages: checked, + } + })}> + + {t("settings:providers.customModel.imageSupport.label")} + + + +
+
+ {t("settings:providers.customModel.imageSupport.description")} +
+
+ +
+
+ { + return { + ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), + supportsComputerUse: checked, + } + })}> + {t("settings:providers.customModel.computerUse.label")} + + +
+
+ {t("settings:providers.customModel.computerUse.description")} +
+
+ +
+
+ { + return { + ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), + supportsPromptCache: checked, + } + })}> + {t("settings:providers.customModel.promptCache.label")} + + +
+
+ {t("settings:providers.customModel.promptCache.description")} +
+
+ +
+ { + const value = apiConfiguration?.openAiCustomModelInfo?.inputPrice + + if (!value && value !== 0) { + return "var(--vscode-input-border)" + } + + return value >= 0 ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)" + })(), + }} + onChange={handleInputChange("openAiCustomModelInfo", (e) => { + const value = (e.target as HTMLInputElement).value + const parsed = parseFloat(value) + + return { + ...(apiConfiguration?.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults), + inputPrice: isNaN(parsed) ? openAiModelInfoSaneDefaults.inputPrice : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.inputPrice")} + className="w-full"> +
+ + +
+
+
+ +
+ { + const value = apiConfiguration?.openAiCustomModelInfo?.outputPrice + + if (!value && value !== 0) { + return "var(--vscode-input-border)" + } + + return value >= 0 ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)" + })(), + }} + onChange={handleInputChange("openAiCustomModelInfo", (e) => { + const value = (e.target as HTMLInputElement).value + const parsed = parseFloat(value) + + return { + ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), + outputPrice: isNaN(parsed) ? openAiModelInfoSaneDefaults.outputPrice : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.outputPrice")} + className="w-full"> +
+ + +
+
+
+ + {apiConfiguration?.openAiCustomModelInfo?.supportsPromptCache && ( + <> +
+ { + const value = apiConfiguration?.openAiCustomModelInfo?.cacheReadsPrice + + if (!value && value !== 0) { + return "var(--vscode-input-border)" + } + + return value >= 0 + ? "var(--vscode-charts-green)" + : "var(--vscode-errorForeground)" + })(), + }} + onChange={handleInputChange("openAiCustomModelInfo", (e) => { + const value = (e.target as HTMLInputElement).value + const parsed = parseFloat(value) + + return { + ...(apiConfiguration?.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults), + cacheReadsPrice: isNaN(parsed) ? 0 : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.inputPrice")} + className="w-full"> +
+ + {t("settings:providers.customModel.pricing.cacheReads.label")} + + +
+
+
+
+ { + const value = apiConfiguration?.openAiCustomModelInfo?.cacheWritesPrice + + if (!value && value !== 0) { + return "var(--vscode-input-border)" + } + + return value >= 0 + ? "var(--vscode-charts-green)" + : "var(--vscode-errorForeground)" + })(), + }} + onChange={handleInputChange("openAiCustomModelInfo", (e) => { + const value = (e.target as HTMLInputElement).value + const parsed = parseFloat(value) + + return { + ...(apiConfiguration?.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults), + cacheWritesPrice: isNaN(parsed) ? 0 : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.cacheWritePrice")} + className="w-full"> +
+ + +
+
+
+ + )} + + +
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/OpenRouter.tsx b/webview-ui/src/components/settings/providers/OpenRouter.tsx new file mode 100644 index 0000000000..e9fd339792 --- /dev/null +++ b/webview-ui/src/components/settings/providers/OpenRouter.tsx @@ -0,0 +1,109 @@ +import { useCallback, useState } from "react" +import { Trans } from "react-i18next" +import { Checkbox } from "vscrui" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { ApiConfiguration } from "@roo/shared/api" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { getOpenRouterAuthUrl } from "@src/oauth/urls" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" + +import { inputEventTransform, noTransform } from "../transforms" + +import { OpenRouterBalanceDisplay } from "./OpenRouterBalanceDisplay" + +type OpenRouterProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void + uriScheme: string | undefined + fromWelcomeView?: boolean +} + +export const OpenRouter = ({ + apiConfiguration, + setApiConfigurationField, + uriScheme, + fromWelcomeView, +}: OpenRouterProps) => { + const { t } = useAppTranslation() + + const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + +
+ + {apiConfiguration?.openRouterApiKey && ( + + )} +
+
+
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.openRouterApiKey && ( + + {t("settings:providers.getOpenRouterApiKey")} + + )} + {!fromWelcomeView && ( + <> +
+ { + setOpenRouterBaseUrlSelected(checked) + + if (!checked) { + setApiConfigurationField("openRouterBaseUrl", "") + } + }}> + {t("settings:providers.useCustomBaseUrl")} + + {openRouterBaseUrlSelected && ( + + )} +
+ + , + }} + /> + + + )} + + ) +} diff --git a/webview-ui/src/components/settings/providers/OpenRouterBalanceDisplay.tsx b/webview-ui/src/components/settings/providers/OpenRouterBalanceDisplay.tsx new file mode 100644 index 0000000000..fd081d1c56 --- /dev/null +++ b/webview-ui/src/components/settings/providers/OpenRouterBalanceDisplay.tsx @@ -0,0 +1,19 @@ +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" + +import { useOpenRouterKeyInfo } from "@/components/ui/hooks/useOpenRouterKeyInfo" + +export const OpenRouterBalanceDisplay = ({ apiKey, baseUrl }: { apiKey: string; baseUrl?: string }) => { + const { data: keyInfo } = useOpenRouterKeyInfo(apiKey, baseUrl) + + if (!keyInfo || !keyInfo.limit) { + return null + } + + const formattedBalance = (keyInfo.limit - keyInfo.usage).toFixed(2) + + return ( + + ${formattedBalance} + + ) +} diff --git a/webview-ui/src/components/settings/transforms.ts b/webview-ui/src/components/settings/transforms.ts new file mode 100644 index 0000000000..22befdaf4e --- /dev/null +++ b/webview-ui/src/components/settings/transforms.ts @@ -0,0 +1,3 @@ +export const noTransform = (value: T) => value + +export const inputEventTransform = (event: E) => (event as { target: HTMLInputElement })?.target?.value as any From bbbbb489db4b649d87c25093346427c1bdccc28d Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 5 May 2025 15:24:26 -0700 Subject: [PATCH 2/4] Forgot this --- .../src/components/settings/ApiOptions.tsx | 897 +----------------- .../settings/OpenRouterBalanceDisplay.tsx | 19 - 2 files changed, 42 insertions(+), 874 deletions(-) delete mode 100644 webview-ui/src/components/settings/OpenRouterBalanceDisplay.tsx diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 5f2d50b94c..50549688fa 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1,25 +1,14 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from "react" import { useDebounce, useEvent } from "react-use" -import { Trans } from "react-i18next" import { LanguageModelChatSelector } from "vscode" import { Checkbox } from "vscrui" -import { - VSCodeButton, - VSCodeLink, - VSCodeRadio, - VSCodeRadioGroup, - VSCodeTextField, -} from "@vscode/webview-ui-toolkit/react" +import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { ExternalLinkIcon } from "@radix-ui/react-icons" -import { ReasoningEffort as ReasoningEffortType } from "@roo/schemas" import { ApiConfiguration, - ModelInfo, - azureOpenAiDefaultApiVersion, glamaDefaultModelId, mistralDefaultModelId, - openAiModelInfoSaneDefaults, openRouterDefaultModelId, unboundDefaultModelId, requestyDefaultModelId, @@ -36,18 +25,22 @@ import { useOpenRouterModelProviders, OPENROUTER_DEFAULT_PROVIDER_NAME, } from "@src/components/ui/hooks/useOpenRouterModelProviders" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button } from "@src/components/ui" -import { getRequestyAuthUrl, getOpenRouterAuthUrl, getGlamaAuthUrl } from "@src/oauth/urls" - -import { VSCodeButtonLink } from "../common/VSCodeButtonLink" - -import { MODELS_BY_PROVIDER, PROVIDERS, VERTEX_REGIONS, REASONING_MODELS, AWS_REGIONS } from "./constants" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { getRequestyAuthUrl, getGlamaAuthUrl } from "@src/oauth/urls" + +// Providers +import { OpenRouter } from "./providers/OpenRouter" +import { OpenAICompatible } from "./providers/OpenAICompatible" +import { Bedrock } from "./providers/Bedrock" +import { LMStudio } from "./providers/LMStudio" + +import { MODELS_BY_PROVIDER, PROVIDERS, VERTEX_REGIONS, REASONING_MODELS } from "./constants" +import { inputEventTransform, noTransform } from "./transforms" import { ModelInfoView } from "./ModelInfoView" import { ModelPicker } from "./ModelPicker" import { ApiErrorMessage } from "./ApiErrorMessage" import { ThinkingBudget } from "./ThinkingBudget" -import { R1FormatSetting } from "./R1FormatSetting" -import { OpenRouterBalanceDisplay } from "./OpenRouterBalanceDisplay" import { RequestyBalanceDisplay } from "./RequestyBalanceDisplay" import { ReasoningEffort } from "./ReasoningEffort" import { PromptCachingControl } from "./PromptCachingControl" @@ -75,11 +68,8 @@ const ApiOptions = ({ const { t } = useAppTranslation() const [ollamaModels, setOllamaModels] = useState([]) - const [lmStudioModels, setLmStudioModels] = useState([]) const [vsCodeLmModels, setVsCodeLmModels] = useState([]) - const [openAiModels, setOpenAiModels] = useState | null>(null) - const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} return Object.entries(headers) @@ -97,63 +87,33 @@ const ApiOptions = ({ const [openAiNativeBaseUrlSelected, setOpenAiNativeBaseUrlSelected] = useState( !!apiConfiguration?.openAiNativeBaseUrl, ) - const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) - const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) - const [openAiLegacyFormatSelected, setOpenAiLegacyFormatSelected] = useState(!!apiConfiguration?.openAiLegacyFormat) + const [googleGeminiBaseUrlSelected, setGoogleGeminiBaseUrlSelected] = useState( !!apiConfiguration?.googleGeminiBaseUrl, ) - const handleAddCustomHeader = useCallback(() => { - // Only update the local state to show the new row in the UI - setCustomHeaders((prev) => [...prev, ["", ""]]) - // Do not update the main configuration yet, wait for user input - }, []) - - const handleUpdateHeaderKey = useCallback((index: number, newKey: string) => { - setCustomHeaders((prev) => { - const updated = [...prev] - if (updated[index]) { - updated[index] = [newKey, updated[index][1]] - } - return updated - }) - }, []) - - const handleUpdateHeaderValue = useCallback((index: number, newValue: string) => { - setCustomHeaders((prev) => { - const updated = [...prev] - if (updated[index]) { - updated[index] = [updated[index][0], newValue] - } - return updated - }) - }, []) - - const handleRemoveCustomHeader = useCallback((index: number) => { - setCustomHeaders((prev) => prev.filter((_, i) => i !== index)) - }, []) - - // Helper to convert array of tuples to object (filtering out empty keys) + // Helper to convert array of tuples to object (filtering out empty keys). const convertHeadersToObject = (headers: [string, string][]): Record => { const result: Record = {} - // Process each header tuple + // Process each header tuple. for (const [key, value] of headers) { const trimmedKey = key.trim() - // Skip empty keys - if (!trimmedKey) continue + // Skip empty keys. + if (!trimmedKey) { + continue + } - // For duplicates, the last one in the array wins - // This matches how HTTP headers work in general + // For duplicates, the last one in the array wins. + // This matches how HTTP headers work in general. result[trimmedKey] = value.trim() } return result } - // Debounced effect to update the main configuration when local customHeaders state stabilizes + // Debounced effect to update the main configuration when local customHeaders state stabilizes. useDebounce( () => { const currentConfigHeaders = apiConfiguration?.openAiHeaders || {} @@ -169,9 +129,6 @@ const ApiOptions = ({ ) const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) - const noTransform = (value: T) => value - - const inputEventTransform = (event: E) => (event as { target: HTMLInputElement })?.target?.value as any const handleInputChange = useCallback( ( @@ -254,23 +211,12 @@ const ApiOptions = ({ const message: ExtensionMessage = event.data switch (message.type) { - case "openAiModels": { - const updatedModels = message.openAiModels ?? [] - setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults]))) - break - } case "ollamaModels": { const newModels = message.ollamaModels ?? [] setOllamaModels(newModels) } break - case "lmStudioModels": - { - const newModels = message.lmStudioModels ?? [] - setLmStudioModels(newModels) - } - break case "vsCodeLmModels": { const newModels = message.vsCodeLmModels ?? [] @@ -403,72 +349,12 @@ const ApiOptions = ({ {errorMessage && } {selectedProvider === "openrouter" && ( - <> - -
- - {apiConfiguration?.openRouterApiKey && ( - - )} -
-
-
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.openRouterApiKey && ( - - {t("settings:providers.getOpenRouterApiKey")} - - )} - {!fromWelcomeView && ( - <> -
- { - setOpenRouterBaseUrlSelected(checked) - - if (!checked) { - setApiConfigurationField("openRouterBaseUrl", "") - } - }}> - {t("settings:providers.useCustomBaseUrl")} - - {openRouterBaseUrlSelected && ( - - )} -
- - , - }} - /> - - - )} - + )} {selectedProvider === "anthropic" && ( @@ -661,99 +547,11 @@ const ApiOptions = ({ )} {selectedProvider === "bedrock" && ( - <> - (e.target as HTMLInputElement).value === "profile", - )}> - {t("settings:providers.awsCredentials")} - {t("settings:providers.awsProfile")} - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {apiConfiguration?.awsUseProfile ? ( - - - - ) : ( - <> - - - - - - - - - - - )} -
- - -
- - {t("settings:providers.awsCrossRegion")} - - {selectedModelInfo?.supportsPromptCache && ( - -
- {t("settings:providers.enablePromptCaching")} - -
-
- )} -
-
- {t("settings:providers.cacheUsageNote")} -
-
- + )} {selectedProvider === "vertex" && ( @@ -869,624 +667,14 @@ const ApiOptions = ({ )} {selectedProvider === "openai" && ( - <> - - - - - - - - -
- { - setOpenAiLegacyFormatSelected(checked) - setApiConfigurationField("openAiLegacyFormat", checked) - }}> - {t("settings:providers.useLegacyFormat")} - -
- - {t("settings:modelInfo.enableStreaming")} - - - {t("settings:modelInfo.useAzure")} - -
- { - setAzureApiVersionSelected(checked) - - if (!checked) { - setApiConfigurationField("azureApiVersion", "") - } - }}> - {t("settings:modelInfo.azureApiVersion")} - - {azureApiVersionSelected && ( - - )} -
- - {/* Custom Headers UI */} -
-
- - - - -
- {!customHeaders.length ? ( -
- {t("settings:providers.noCustomHeaders")} -
- ) : ( - customHeaders.map(([key, value], index) => ( -
- handleUpdateHeaderKey(index, e.target.value)} - /> - handleUpdateHeaderValue(index, e.target.value)} - /> - handleRemoveCustomHeader(index)}> - - -
- )) - )} -
- -
- { - setApiConfigurationField("enableReasoningEffort", checked) - - if (!checked) { - const { reasoningEffort: _, ...openAiCustomModelInfo } = - apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults - - setApiConfigurationField("openAiCustomModelInfo", openAiCustomModelInfo) - } - }}> - {t("settings:providers.setReasoningLevel")} - - {!!apiConfiguration.enableReasoningEffort && ( - { - if (field === "reasoningEffort") { - const openAiCustomModelInfo = - apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults - - setApiConfigurationField("openAiCustomModelInfo", { - ...openAiCustomModelInfo, - reasoningEffort: value as ReasoningEffortType, - }) - } - }} - /> - )} -
-
-
- {t("settings:providers.customModel.capabilities")} -
- -
- { - const value = apiConfiguration?.openAiCustomModelInfo?.maxTokens - - if (!value) { - return "var(--vscode-input-border)" - } - - return value > 0 - ? "var(--vscode-charts-green)" - : "var(--vscode-errorForeground)" - })(), - }} - title={t("settings:providers.customModel.maxTokens.description")} - onInput={handleInputChange("openAiCustomModelInfo", (e) => { - const value = parseInt((e.target as HTMLInputElement).value) - - return { - ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), - maxTokens: isNaN(value) ? undefined : value, - } - })} - placeholder={t("settings:placeholders.numbers.maxTokens")} - className="w-full"> - - -
- {t("settings:providers.customModel.maxTokens.description")} -
-
- -
- { - const value = apiConfiguration?.openAiCustomModelInfo?.contextWindow - - if (!value) { - return "var(--vscode-input-border)" - } - - return value > 0 - ? "var(--vscode-charts-green)" - : "var(--vscode-errorForeground)" - })(), - }} - title={t("settings:providers.customModel.contextWindow.description")} - onInput={handleInputChange("openAiCustomModelInfo", (e) => { - const value = (e.target as HTMLInputElement).value - const parsed = parseInt(value) - - return { - ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), - contextWindow: isNaN(parsed) - ? openAiModelInfoSaneDefaults.contextWindow - : parsed, - } - })} - placeholder={t("settings:placeholders.numbers.contextWindow")} - className="w-full"> - - -
- {t("settings:providers.customModel.contextWindow.description")} -
-
- -
-
- { - return { - ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), - supportsImages: checked, - } - })}> - - {t("settings:providers.customModel.imageSupport.label")} - - - -
-
- {t("settings:providers.customModel.imageSupport.description")} -
-
- -
-
- { - return { - ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), - supportsComputerUse: checked, - } - })}> - - {t("settings:providers.customModel.computerUse.label")} - - - -
-
- {t("settings:providers.customModel.computerUse.description")} -
-
- -
-
- { - return { - ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), - supportsPromptCache: checked, - } - })}> - - {t("settings:providers.customModel.promptCache.label")} - - - -
-
- {t("settings:providers.customModel.promptCache.description")} -
-
- -
- { - const value = apiConfiguration?.openAiCustomModelInfo?.inputPrice - - if (!value && value !== 0) { - return "var(--vscode-input-border)" - } - - return value >= 0 - ? "var(--vscode-charts-green)" - : "var(--vscode-errorForeground)" - })(), - }} - onChange={handleInputChange("openAiCustomModelInfo", (e) => { - const value = (e.target as HTMLInputElement).value - const parsed = parseFloat(value) - - return { - ...(apiConfiguration?.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults), - inputPrice: isNaN(parsed) ? openAiModelInfoSaneDefaults.inputPrice : parsed, - } - })} - placeholder={t("settings:placeholders.numbers.inputPrice")} - className="w-full"> -
- - -
-
-
- -
- { - const value = apiConfiguration?.openAiCustomModelInfo?.outputPrice - - if (!value && value !== 0) { - return "var(--vscode-input-border)" - } - - return value >= 0 - ? "var(--vscode-charts-green)" - : "var(--vscode-errorForeground)" - })(), - }} - onChange={handleInputChange("openAiCustomModelInfo", (e) => { - const value = (e.target as HTMLInputElement).value - const parsed = parseFloat(value) - - return { - ...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults), - outputPrice: isNaN(parsed) ? openAiModelInfoSaneDefaults.outputPrice : parsed, - } - })} - placeholder={t("settings:placeholders.numbers.outputPrice")} - className="w-full"> -
- - -
-
-
- - {apiConfiguration?.openAiCustomModelInfo?.supportsPromptCache && ( - <> -
- { - const value = apiConfiguration?.openAiCustomModelInfo?.cacheReadsPrice - - if (!value && value !== 0) { - return "var(--vscode-input-border)" - } - - return value >= 0 - ? "var(--vscode-charts-green)" - : "var(--vscode-errorForeground)" - })(), - }} - onChange={handleInputChange("openAiCustomModelInfo", (e) => { - const value = (e.target as HTMLInputElement).value - const parsed = parseFloat(value) - - return { - ...(apiConfiguration?.openAiCustomModelInfo ?? - openAiModelInfoSaneDefaults), - cacheReadsPrice: isNaN(parsed) ? 0 : parsed, - } - })} - placeholder={t("settings:placeholders.numbers.inputPrice")} - className="w-full"> -
- - {t("settings:providers.customModel.pricing.cacheReads.label")} - - -
-
-
-
- { - const value = apiConfiguration?.openAiCustomModelInfo?.cacheWritesPrice - - if (!value && value !== 0) { - return "var(--vscode-input-border)" - } - - return value >= 0 - ? "var(--vscode-charts-green)" - : "var(--vscode-errorForeground)" - })(), - }} - onChange={handleInputChange("openAiCustomModelInfo", (e) => { - const value = (e.target as HTMLInputElement).value - const parsed = parseFloat(value) - - return { - ...(apiConfiguration?.openAiCustomModelInfo ?? - openAiModelInfoSaneDefaults), - cacheWritesPrice: isNaN(parsed) ? 0 : parsed, - } - })} - placeholder={t("settings:placeholders.numbers.cacheWritePrice")} - className="w-full"> -
- - -
-
-
- - )} - - -
- + )} {selectedProvider === "lmstudio" && ( - <> - - - - - - - {lmStudioModels.length > 0 && ( - - {lmStudioModels.map((model) => ( - - {model} - - ))} - - )} - { - setApiConfigurationField("lmStudioSpeculativeDecodingEnabled", checked) - }}> - {t("settings:providers.lmStudio.speculativeDecoding")} - - {apiConfiguration?.lmStudioSpeculativeDecodingEnabled && ( - <> -
- - - -
- {t("settings:providers.lmStudio.draftModelDesc")} -
-
- {lmStudioModels.length > 0 && ( - <> -
- {t("settings:providers.lmStudio.selectDraftModel")} -
- - {lmStudioModels.map((model) => ( - - {model} - - ))} - - {lmStudioModels.length === 0 && ( -
- {t("settings:providers.lmStudio.noModelsFound")} -
- )} - - )} - - )} -
- , - b: , - span: ( - - Note: - - ), - }} - /> -
- + )} {selectedProvider === "deepseek" && ( @@ -1647,12 +835,11 @@ const ApiOptions = ({
{t("settings:providers.apiKeyStorageNotice")}
- {/* Add a link to get Chutes API key if available */} - {/* {!apiConfiguration?.chutesApiKey && ( - + {!apiConfiguration?.chutesApiKey && ( + {t("settings:providers.getChutesApiKey")} - )} */} + )} )} diff --git a/webview-ui/src/components/settings/OpenRouterBalanceDisplay.tsx b/webview-ui/src/components/settings/OpenRouterBalanceDisplay.tsx deleted file mode 100644 index fd081d1c56..0000000000 --- a/webview-ui/src/components/settings/OpenRouterBalanceDisplay.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" - -import { useOpenRouterKeyInfo } from "@/components/ui/hooks/useOpenRouterKeyInfo" - -export const OpenRouterBalanceDisplay = ({ apiKey, baseUrl }: { apiKey: string; baseUrl?: string }) => { - const { data: keyInfo } = useOpenRouterKeyInfo(apiKey, baseUrl) - - if (!keyInfo || !keyInfo.limit) { - return null - } - - const formattedBalance = (keyInfo.limit - keyInfo.usage).toFixed(2) - - return ( - - ${formattedBalance} - - ) -} From bb4a8cce2ac09574095dd37e0e11db0a1982bee8 Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 5 May 2025 15:28:02 -0700 Subject: [PATCH 3/4] Fix tests --- src/api/providers/__tests__/chutes.test.ts | 9 ++++----- src/api/providers/__tests__/groq.test.ts | 17 ++++++++--------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/api/providers/__tests__/chutes.test.ts b/src/api/providers/__tests__/chutes.test.ts index 946aff96c8..63af600be7 100644 --- a/src/api/providers/__tests__/chutes.test.ts +++ b/src/api/providers/__tests__/chutes.test.ts @@ -19,11 +19,11 @@ describe("ChutesHandler", () => { beforeEach(() => { jest.clearAllMocks() mockCreate = (OpenAI as unknown as jest.Mock)().chat.completions.create - handler = new ChutesHandler({}) + handler = new ChutesHandler({ chutesApiKey: "test-chutes-api-key" }) }) test("should use the correct Chutes base URL", () => { - new ChutesHandler({}) + new ChutesHandler({ chutesApiKey: "test-chutes-api-key" }) expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://llm.chutes.ai/v1" })) }) @@ -41,9 +41,8 @@ describe("ChutesHandler", () => { test("should return specified model when valid model is provided", () => { const testModelId: ChutesModelId = "deepseek-ai/DeepSeek-R1" - const handlerWithModel = new ChutesHandler({ apiModelId: testModelId }) + const handlerWithModel = new ChutesHandler({ apiModelId: testModelId, chutesApiKey: "test-chutes-api-key" }) const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) expect(model.info).toEqual(chutesModels[testModelId]) }) @@ -110,7 +109,7 @@ describe("ChutesHandler", () => { test("createMessage should pass correct parameters to Chutes client", async () => { const modelId: ChutesModelId = "deepseek-ai/DeepSeek-R1" const modelInfo = chutesModels[modelId] - const handlerWithModel = new ChutesHandler({ apiModelId: modelId }) + const handlerWithModel = new ChutesHandler({ apiModelId: modelId, chutesApiKey: "test-chutes-api-key" }) mockCreate.mockImplementationOnce(() => { return { diff --git a/src/api/providers/__tests__/groq.test.ts b/src/api/providers/__tests__/groq.test.ts index 6b38dcc927..068f7248fd 100644 --- a/src/api/providers/__tests__/groq.test.ts +++ b/src/api/providers/__tests__/groq.test.ts @@ -19,11 +19,11 @@ describe("GroqHandler", () => { beforeEach(() => { jest.clearAllMocks() mockCreate = (OpenAI as unknown as jest.Mock)().chat.completions.create - handler = new GroqHandler({}) + handler = new GroqHandler({ groqApiKey: "test-groq-api-key" }) }) test("should use the correct Groq base URL", () => { - new GroqHandler({}) + new GroqHandler({ groqApiKey: "test-groq-api-key" }) expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.groq.com/openai/v1" })) }) @@ -35,17 +35,16 @@ describe("GroqHandler", () => { test("should return default model when no model is specified", () => { const model = handler.getModel() - expect(model.id).toBe(groqDefaultModelId) // Use groqDefaultModelId - expect(model.info).toEqual(groqModels[groqDefaultModelId]) // Use groqModels + expect(model.id).toBe(groqDefaultModelId) + expect(model.info).toEqual(groqModels[groqDefaultModelId]) }) test("should return specified model when valid model is provided", () => { - const testModelId: GroqModelId = "llama-3.3-70b-versatile" // Use a valid Groq model ID and type - const handlerWithModel = new GroqHandler({ apiModelId: testModelId }) // Instantiate GroqHandler + const testModelId: GroqModelId = "llama-3.3-70b-versatile" + const handlerWithModel = new GroqHandler({ apiModelId: testModelId, groqApiKey: "test-groq-api-key" }) const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(groqModels[testModelId]) // Use groqModels + expect(model.info).toEqual(groqModels[testModelId]) }) test("completePrompt method should return text from Groq API", async () => { @@ -110,7 +109,7 @@ describe("GroqHandler", () => { test("createMessage should pass correct parameters to Groq client", async () => { const modelId: GroqModelId = "llama-3.1-8b-instant" const modelInfo = groqModels[modelId] - const handlerWithModel = new GroqHandler({ apiModelId: modelId }) + const handlerWithModel = new GroqHandler({ apiModelId: modelId, groqApiKey: "test-groq-api-key" }) mockCreate.mockImplementationOnce(() => { return { From de240fc31d937a455aa483d26209121f286860ae Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 5 May 2025 15:49:32 -0700 Subject: [PATCH 4/4] More progress --- .../src/components/settings/ApiOptions.tsx | 344 ++---------------- .../settings/providers/Anthropic.tsx | 84 +++++ .../components/settings/providers/Gemini.tsx | 77 ++++ .../components/settings/providers/Ollama.tsx | 86 +++++ .../components/settings/providers/OpenAI.tsx | 77 ++++ .../settings/providers/VSCodeLM.tsx | 86 +++++ .../components/settings/providers/Vertex.tsx | 97 +++++ 7 files changed, 530 insertions(+), 321 deletions(-) create mode 100644 webview-ui/src/components/settings/providers/Anthropic.tsx create mode 100644 webview-ui/src/components/settings/providers/Gemini.tsx create mode 100644 webview-ui/src/components/settings/providers/Ollama.tsx create mode 100644 webview-ui/src/components/settings/providers/OpenAI.tsx create mode 100644 webview-ui/src/components/settings/providers/VSCodeLM.tsx create mode 100644 webview-ui/src/components/settings/providers/Vertex.tsx diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 50549688fa..e662c1a3ad 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1,8 +1,6 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from "react" -import { useDebounce, useEvent } from "react-use" -import { LanguageModelChatSelector } from "vscode" -import { Checkbox } from "vscrui" -import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useDebounce } from "react-use" +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { ExternalLinkIcon } from "@radix-ui/react-icons" import { @@ -14,7 +12,6 @@ import { requestyDefaultModelId, ApiProvider, } from "@roo/shared/api" -import { ExtensionMessage } from "@roo/shared/ExtensionMessage" import { vscode } from "@src/utils/vscode" import { validateApiConfiguration, validateModelId, validateBedrockArn } from "@src/utils/validate" @@ -30,12 +27,18 @@ import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { getRequestyAuthUrl, getGlamaAuthUrl } from "@src/oauth/urls" // Providers -import { OpenRouter } from "./providers/OpenRouter" -import { OpenAICompatible } from "./providers/OpenAICompatible" +import { Anthropic } from "./providers/Anthropic" import { Bedrock } from "./providers/Bedrock" +import { Gemini } from "./providers/Gemini" import { LMStudio } from "./providers/LMStudio" +import { Ollama } from "./providers/Ollama" +import { OpenAI } from "./providers/OpenAI" +import { OpenAICompatible } from "./providers/OpenAICompatible" +import { OpenRouter } from "./providers/OpenRouter" +import { Vertex } from "./providers/Vertex" +import { VSCodeLM } from "./providers/VSCodeLM" -import { MODELS_BY_PROVIDER, PROVIDERS, VERTEX_REGIONS, REASONING_MODELS } from "./constants" +import { MODELS_BY_PROVIDER, PROVIDERS, REASONING_MODELS } from "./constants" import { inputEventTransform, noTransform } from "./transforms" import { ModelInfoView } from "./ModelInfoView" import { ModelPicker } from "./ModelPicker" @@ -67,9 +70,6 @@ const ApiOptions = ({ }: ApiOptionsProps) => { const { t } = useAppTranslation() - const [ollamaModels, setOllamaModels] = useState([]) - const [vsCodeLmModels, setVsCodeLmModels] = useState([]) - const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} return Object.entries(headers) @@ -83,15 +83,6 @@ const ApiOptions = ({ } }, [apiConfiguration?.openAiHeaders, customHeaders]) - const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) - const [openAiNativeBaseUrlSelected, setOpenAiNativeBaseUrlSelected] = useState( - !!apiConfiguration?.openAiNativeBaseUrl, - ) - - const [googleGeminiBaseUrlSelected, setGoogleGeminiBaseUrlSelected] = useState( - !!apiConfiguration?.googleGeminiBaseUrl, - ) - // Helper to convert array of tuples to object (filtering out empty keys). const convertHeadersToObject = (headers: [string, string][]): Record => { const result: Record = {} @@ -161,8 +152,9 @@ const ApiOptions = ({ useDebounce( () => { if (selectedProvider === "openai") { - // Use our custom headers state to build the headers object + // Use our custom headers state to build the headers object. const headerObject = convertHeadersToObject(customHeaders) + vscode.postMessage({ type: "requestOpenAiModels", values: { @@ -195,6 +187,7 @@ const ApiOptions = ({ useEffect(() => { const apiValidationResult = validateApiConfiguration(apiConfiguration) || validateModelId(apiConfiguration, routerModels) + setErrorMessage(apiValidationResult) }, [apiConfiguration, routerModels, setErrorMessage]) @@ -207,27 +200,6 @@ const ApiOptions = ({ apiConfiguration.openRouterModelId in routerModels.openrouter, }) - const onMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data - - switch (message.type) { - case "ollamaModels": - { - const newModels = message.ollamaModels ?? [] - setOllamaModels(newModels) - } - break - case "vsCodeLmModels": - { - const newModels = message.vsCodeLmModels ?? [] - setVsCodeLmModels(newModels) - } - break - } - }, []) - - useEvent("message", onMessage) - const selectedProviderModelOptions = useMemo( () => MODELS_BY_PROVIDER[selectedProvider] @@ -239,16 +211,13 @@ const ApiOptions = ({ [selectedProvider], ) - // Base URL for provider documentation - const DOC_BASE_URL = "https://docs.roocode.com/providers" - - // Custom URL path mappings for providers with different slugs + // Custom URL path mappings for providers with different slugs. const providerUrlSlugs: Record = { "openai-native": "openai", openai: "openai-compatible", } - // Helper function to get provider display name from PROVIDERS constant + // Helper function to get provider display name from PROVIDERS constant. const getProviderDisplayName = (providerKey: string): string | undefined => { const provider = PROVIDERS.find((p) => p.value === providerKey) return provider?.label @@ -266,7 +235,7 @@ const ApiOptions = ({ const urlSlug = providerUrlSlugs[selectedProvider] || selectedProvider return { - url: `${DOC_BASE_URL}/${urlSlug}`, + url: `https://docs.roocode.com/providers/${urlSlug}`, name: displayName, } } @@ -358,57 +327,7 @@ const ApiOptions = ({ )} {selectedProvider === "anthropic" && ( - <> - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.apiKey && ( - - {t("settings:providers.getAnthropicApiKey")} - - )} -
- { - setAnthropicBaseUrlSelected(checked) - - if (!checked) { - setApiConfigurationField("anthropicBaseUrl", "") - setApiConfigurationField("anthropicUseAuthToken", false) // added - } - }}> - {t("settings:providers.useCustomBaseUrl")} - - {anthropicBaseUrlSelected && ( - <> - - - {/* added */} - - {t("settings:providers.anthropicUseAuthToken")} - - - )} -
- + )} {selectedProvider === "glama" && ( @@ -465,46 +384,7 @@ const ApiOptions = ({ )} {selectedProvider === "openai-native" && ( - <> - { - setOpenAiNativeBaseUrlSelected(checked) - - if (!checked) { - setApiConfigurationField("openAiNativeBaseUrl", "") - } - }}> - {t("settings:providers.useCustomBaseUrl")} - - {openAiNativeBaseUrlSelected && ( - <> - - - )} - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.openAiNativeApiKey && ( - - {t("settings:providers.getOpenAiApiKey")} - - )} - + )} {selectedProvider === "mistral" && ( @@ -555,115 +435,11 @@ const ApiOptions = ({ )} {selectedProvider === "vertex" && ( - <> -
-
{t("settings:providers.googleCloudSetup.title")}
-
- - {t("settings:providers.googleCloudSetup.step1")} - -
-
- - {t("settings:providers.googleCloudSetup.step2")} - -
-
- - {t("settings:providers.googleCloudSetup.step3")} - -
-
- - - - - - - - - -
- - -
- + )} {selectedProvider === "gemini" && ( - <> - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.geminiApiKey && ( - - {t("settings:providers.getGeminiApiKey")} - - )} -
- { - setGoogleGeminiBaseUrlSelected(checked) - - if (!checked) { - setApiConfigurationField("googleGeminiBaseUrl", "") - } - }}> - {t("settings:providers.useCustomBaseUrl")} - - {googleGeminiBaseUrlSelected && ( - - )} -
- + )} {selectedProvider === "openai" && ( @@ -699,85 +475,11 @@ const ApiOptions = ({ )} {selectedProvider === "vscode-lm" && ( - <> -
- - {vsCodeLmModels.length > 0 ? ( - - ) : ( -
- {t("settings:providers.vscodeLmDescription")} -
- )} -
-
{t("settings:providers.vscodeLmWarning")}
- + )} {selectedProvider === "ollama" && ( - <> - - - - - - - {ollamaModels.length > 0 && ( - - {ollamaModels.map((model) => ( - - {model} - - ))} - - )} -
- {t("settings:providers.ollama.description")} - - {t("settings:providers.ollama.warning")} - -
- + )} {selectedProvider === "xai" && ( diff --git a/webview-ui/src/components/settings/providers/Anthropic.tsx b/webview-ui/src/components/settings/providers/Anthropic.tsx new file mode 100644 index 0000000000..bf310d1cb7 --- /dev/null +++ b/webview-ui/src/components/settings/providers/Anthropic.tsx @@ -0,0 +1,84 @@ +import { useCallback, useState } from "react" +import { Checkbox } from "vscrui" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { ApiConfiguration } from "@roo/shared/api" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" + +import { inputEventTransform, noTransform } from "../transforms" + +type AnthropicProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void +} + +export const Anthropic = ({ apiConfiguration, setApiConfigurationField }: AnthropicProps) => { + const { t } = useAppTranslation() + + const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.apiKey && ( + + {t("settings:providers.getAnthropicApiKey")} + + )} +
+ { + setAnthropicBaseUrlSelected(checked) + + if (!checked) { + setApiConfigurationField("anthropicBaseUrl", "") + setApiConfigurationField("anthropicUseAuthToken", false) + } + }}> + {t("settings:providers.useCustomBaseUrl")} + + {anthropicBaseUrlSelected && ( + <> + + + {t("settings:providers.anthropicUseAuthToken")} + + + )} +
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/Gemini.tsx b/webview-ui/src/components/settings/providers/Gemini.tsx new file mode 100644 index 0000000000..490ab73a51 --- /dev/null +++ b/webview-ui/src/components/settings/providers/Gemini.tsx @@ -0,0 +1,77 @@ +import { useCallback, useState } from "react" +import { Checkbox } from "vscrui" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { ApiConfiguration } from "@roo/shared/api" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" + +import { inputEventTransform } from "../transforms" + +type GeminiProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void +} + +export const Gemini = ({ apiConfiguration, setApiConfigurationField }: GeminiProps) => { + const { t } = useAppTranslation() + + const [googleGeminiBaseUrlSelected, setGoogleGeminiBaseUrlSelected] = useState( + !!apiConfiguration?.googleGeminiBaseUrl, + ) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.geminiApiKey && ( + + {t("settings:providers.getGeminiApiKey")} + + )} +
+ { + setGoogleGeminiBaseUrlSelected(checked) + + if (!checked) { + setApiConfigurationField("googleGeminiBaseUrl", "") + } + }}> + {t("settings:providers.useCustomBaseUrl")} + + {googleGeminiBaseUrlSelected && ( + + )} +
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/Ollama.tsx b/webview-ui/src/components/settings/providers/Ollama.tsx new file mode 100644 index 0000000000..5646fae18c --- /dev/null +++ b/webview-ui/src/components/settings/providers/Ollama.tsx @@ -0,0 +1,86 @@ +import { useState, useCallback } from "react" +import { useEvent } from "react-use" +import { VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react" + +import { ApiConfiguration } from "@roo/shared/api" +import { ExtensionMessage } from "@roo/shared/ExtensionMessage" + +import { useAppTranslation } from "@src/i18n/TranslationContext" + +import { inputEventTransform } from "../transforms" + +type OllamaProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void +} + +export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaProps) => { + const { t } = useAppTranslation() + + const [ollamaModels, setOllamaModels] = useState([]) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + const onMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + + switch (message.type) { + case "ollamaModels": + { + const newModels = message.ollamaModels ?? [] + setOllamaModels(newModels) + } + break + } + }, []) + + useEvent("message", onMessage) + + return ( + <> + + + + + + + {ollamaModels.length > 0 && ( + + {ollamaModels.map((model) => ( + + {model} + + ))} + + )} +
+ {t("settings:providers.ollama.description")} + {t("settings:providers.ollama.warning")} +
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/OpenAI.tsx b/webview-ui/src/components/settings/providers/OpenAI.tsx new file mode 100644 index 0000000000..501dc9df55 --- /dev/null +++ b/webview-ui/src/components/settings/providers/OpenAI.tsx @@ -0,0 +1,77 @@ +import { useCallback, useState } from "react" +import { Checkbox } from "vscrui" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { ApiConfiguration } from "@roo/shared/api" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" + +import { inputEventTransform } from "../transforms" + +type OpenAIProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void +} + +export const OpenAI = ({ apiConfiguration, setApiConfigurationField }: OpenAIProps) => { + const { t } = useAppTranslation() + + const [openAiNativeBaseUrlSelected, setOpenAiNativeBaseUrlSelected] = useState( + !!apiConfiguration?.openAiNativeBaseUrl, + ) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + { + setOpenAiNativeBaseUrlSelected(checked) + + if (!checked) { + setApiConfigurationField("openAiNativeBaseUrl", "") + } + }}> + {t("settings:providers.useCustomBaseUrl")} + + {openAiNativeBaseUrlSelected && ( + <> + + + )} + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.openAiNativeApiKey && ( + + {t("settings:providers.getOpenAiApiKey")} + + )} + + ) +} diff --git a/webview-ui/src/components/settings/providers/VSCodeLM.tsx b/webview-ui/src/components/settings/providers/VSCodeLM.tsx new file mode 100644 index 0000000000..ca701ce024 --- /dev/null +++ b/webview-ui/src/components/settings/providers/VSCodeLM.tsx @@ -0,0 +1,86 @@ +import { useState, useCallback } from "react" +import { useEvent } from "react-use" +import { LanguageModelChatSelector } from "vscode" + +import { ApiConfiguration } from "@roo/shared/api" +import { ExtensionMessage } from "@roo/shared/ExtensionMessage" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" + +import { inputEventTransform } from "../transforms" + +type VSCodeLMProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void +} + +export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeLMProps) => { + const { t } = useAppTranslation() + + const [vsCodeLmModels, setVsCodeLmModels] = useState([]) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + const onMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + + switch (message.type) { + case "vsCodeLmModels": + { + const newModels = message.vsCodeLmModels ?? [] + setVsCodeLmModels(newModels) + } + break + } + }, []) + + useEvent("message", onMessage) + + return ( + <> +
+ + {vsCodeLmModels.length > 0 ? ( + + ) : ( +
+ {t("settings:providers.vscodeLmDescription")} +
+ )} +
+
{t("settings:providers.vscodeLmWarning")}
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/Vertex.tsx b/webview-ui/src/components/settings/providers/Vertex.tsx new file mode 100644 index 0000000000..f0723f9d31 --- /dev/null +++ b/webview-ui/src/components/settings/providers/Vertex.tsx @@ -0,0 +1,97 @@ +import { useCallback } from "react" +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { ApiConfiguration } from "@roo/shared/api" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" + +import { inputEventTransform } from "../transforms" +import { VERTEX_REGIONS } from "../constants" + +type VertexProps = { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void +} + +export const Vertex = ({ apiConfiguration, setApiConfigurationField }: VertexProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> +
+
{t("settings:providers.googleCloudSetup.title")}
+
+ + {t("settings:providers.googleCloudSetup.step1")} + +
+
+ + {t("settings:providers.googleCloudSetup.step2")} + +
+
+ + {t("settings:providers.googleCloudSetup.step3")} + +
+
+ + + + + + + + + +
+ + +
+ + ) +}