diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 9b1082664cd..c88dcc2051b 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -1834,6 +1834,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
if (!apiKey) {
apiKey = (await this.getSecret("requestyApiKey")) as string
}
+
+ if (!apiKey) {
+ this.outputChannel.appendLine("No Requesty API key found")
+ return models
+ }
+
if (apiKey) {
config["headers"] = { Authorization: `Bearer ${apiKey}` }
}
@@ -1884,7 +1890,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("Invalid response from Requesty API")
}
await fs.writeFile(requestyModelsFilePath, JSON.stringify(models))
- this.outputChannel.appendLine(`Requesty models fetched and saved: ${JSON.stringify(models, null, 2)}`)
} catch (error) {
this.outputChannel.appendLine(
`Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx
index 47b4e10e212..64df27496e2 100644
--- a/webview-ui/src/components/settings/ApiOptions.tsx
+++ b/webview-ui/src/components/settings/ApiOptions.tsx
@@ -251,7 +251,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.requestyApiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("requestyApiKey")}
+ onBlur={handleInputChange("requestyApiKey")}
placeholder="Enter API Key...">
Requesty API Key
diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx
index f45f857d9d0..b21b37ef0f4 100644
--- a/webview-ui/src/components/settings/ModelPicker.tsx
+++ b/webview-ui/src/components/settings/ModelPicker.tsx
@@ -1,6 +1,6 @@
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
-import { useMemo, useState, useCallback, useEffect } from "react"
+import { useMemo, useState, useCallback, useEffect, useRef } from "react"
import { useMount } from "react-use"
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
@@ -23,15 +23,24 @@ import { vscode } from "../../utils/vscode"
import { normalizeApiConfiguration } from "./ApiOptions"
import { ModelInfoView } from "./ModelInfoView"
-interface ModelPickerProps {
+type ModelProvider = "glama" | "openRouter" | "unbound" | "requesty" | "openAi"
+
+type ModelKeys = `${T}Models`
+type ConfigKeys = `${T}ModelId`
+type InfoKeys = `${T}ModelInfo`
+type RefreshMessageType = `refresh${Capitalize}Models`
+
+interface ModelPickerProps {
defaultModelId: string
- modelsKey: "glamaModels" | "openRouterModels" | "unboundModels" | "requestyModels"
- configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" | "requestyModelId"
- infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" | "requestyModelInfo"
- refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshUnboundModels" | "refreshRequestyModels"
+ modelsKey: ModelKeys
+ configKey: ConfigKeys
+ infoKey: InfoKeys
+ refreshMessageType: RefreshMessageType
+ refreshValues?: Record
serviceName: string
serviceUrl: string
recommendedModel: string
+ allowCustomModel?: boolean
}
export const ModelPicker = ({
@@ -40,25 +49,51 @@ export const ModelPicker = ({
configKey,
infoKey,
refreshMessageType,
+ refreshValues,
serviceName,
serviceUrl,
recommendedModel,
+ allowCustomModel = false,
}: ModelPickerProps) => {
+ const [customModelId, setCustomModelId] = useState("")
+ const [isCustomModel, setIsCustomModel] = useState(false)
const [open, setOpen] = useState(false)
const [value, setValue] = useState(defaultModelId)
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
+ const prevRefreshValuesRef = useRef | undefined>()
- const { apiConfiguration, setApiConfiguration, [modelsKey]: models, onUpdateApiConfig } = useExtensionState()
- const modelIds = useMemo(() => Object.keys(models).sort((a, b) => a.localeCompare(b)), [models])
+ const { apiConfiguration, [modelsKey]: models, onUpdateApiConfig, setApiConfiguration } = useExtensionState()
+
+ const modelIds = useMemo(
+ () => (Array.isArray(models) ? models : Object.keys(models)).sort((a, b) => a.localeCompare(b)),
+ [models],
+ )
const { selectedModelId, selectedModelInfo } = useMemo(
() => normalizeApiConfiguration(apiConfiguration),
[apiConfiguration],
)
+ const onSelectCustomModel = useCallback(
+ (modelId: string) => {
+ setCustomModelId(modelId)
+ const modelInfo = { id: modelId }
+ const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
+ setApiConfiguration(apiConfig)
+ onUpdateApiConfig(apiConfig)
+ setValue(modelId)
+ setOpen(false)
+ setIsCustomModel(false)
+ },
+ [apiConfiguration, configKey, infoKey, onUpdateApiConfig, setApiConfiguration],
+ )
+
const onSelect = useCallback(
(modelId: string) => {
- const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: models[modelId] }
+ const modelInfo = Array.isArray(models)
+ ? { id: modelId } // For OpenAI models which are just strings
+ : models[modelId] // For other models that have full info objects
+ const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setValue(modelId)
@@ -67,16 +102,42 @@ export const ModelPicker = ({
[apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration],
)
- const debouncedRefreshModels = useMemo(
- () => debounce(() => vscode.postMessage({ type: refreshMessageType }), 50),
- [refreshMessageType],
- )
+ const debouncedRefreshModels = useMemo(() => {
+ return debounce(() => {
+ const message = refreshValues
+ ? { type: refreshMessageType, values: refreshValues }
+ : { type: refreshMessageType }
+ vscode.postMessage(message)
+ }, 100)
+ }, [refreshMessageType, refreshValues])
useMount(() => {
debouncedRefreshModels()
return () => debouncedRefreshModels.clear()
})
+ useEffect(() => {
+ if (!refreshValues) {
+ prevRefreshValuesRef.current = undefined
+ return
+ }
+
+ // Check if all values in refreshValues are truthy
+ if (Object.values(refreshValues).some((value) => !value)) {
+ prevRefreshValuesRef.current = undefined
+ return
+ }
+
+ // Compare with previous values
+ const prevValues = prevRefreshValuesRef.current
+ if (prevValues && JSON.stringify(prevValues) === JSON.stringify(refreshValues)) {
+ return
+ }
+
+ prevRefreshValuesRef.current = refreshValues
+ debouncedRefreshModels()
+ }, [debouncedRefreshModels, refreshValues])
+
useEffect(() => setValue(selectedModelId), [selectedModelId])
return (
@@ -104,6 +165,17 @@ export const ModelPicker = ({
))}
+ {allowCustomModel && (
+
+ {
+ setIsCustomModel(true)
+ setOpen(false)
+ }}>
+ + Add custom model
+
+
+ )}
@@ -125,6 +197,28 @@ export const ModelPicker = ({
onSelect(recommendedModel)}>{recommendedModel}.
You can also try searching "free" for no-cost options currently available.
+ {allowCustomModel && isCustomModel && (
+
+
+
Add Custom Model
+
setCustomModelId(e.target.value)}
+ />
+
+
+
+
+
+
+ )}
>
)
}
diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx
index a8243547c67..040da1d4210 100644
--- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx
+++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx
@@ -1,217 +1,27 @@
-import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import debounce from "debounce"
-import { Fzf } from "fzf"
-import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
-
+import React from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
-import { vscode } from "../../utils/vscode"
-import { highlightFzfMatch } from "../../utils/highlight"
-import { DropdownWrapper, DropdownList, DropdownItem } from "./styles"
+import { ModelPicker } from "./ModelPicker"
const OpenAiModelPicker: React.FC = () => {
- const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
- const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "")
- const [isDropdownVisible, setIsDropdownVisible] = useState(false)
- const [selectedIndex, setSelectedIndex] = useState(-1)
- const dropdownRef = useRef(null)
- const itemRefs = useRef<(HTMLDivElement | null)[]>([])
- const dropdownListRef = useRef(null)
-
- const handleModelChange = (newModelId: string) => {
- // could be setting invalid model id/undefined info but validation will catch it
- const apiConfig = {
- ...apiConfiguration,
- openAiModelId: newModelId,
- }
-
- setApiConfiguration(apiConfig)
- onUpdateApiConfig(apiConfig)
- setSearchTerm(newModelId)
- }
-
- useEffect(() => {
- if (apiConfiguration?.openAiModelId && apiConfiguration?.openAiModelId !== searchTerm) {
- setSearchTerm(apiConfiguration?.openAiModelId)
- }
- }, [apiConfiguration, searchTerm])
-
- const debouncedRefreshModels = useMemo(
- () =>
- debounce((baseUrl: string, apiKey: string) => {
- vscode.postMessage({
- type: "refreshOpenAiModels",
- values: {
- baseUrl,
- apiKey,
- },
- })
- }, 50),
- [],
- )
-
- useEffect(() => {
- if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) {
- return
- }
-
- debouncedRefreshModels(apiConfiguration.openAiBaseUrl, apiConfiguration.openAiApiKey)
-
- // Cleanup debounced function
- return () => {
- debouncedRefreshModels.clear()
- }
- }, [apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey, debouncedRefreshModels])
-
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
- setIsDropdownVisible(false)
- }
- }
-
- document.addEventListener("mousedown", handleClickOutside)
- return () => {
- document.removeEventListener("mousedown", handleClickOutside)
- }
- }, [])
-
- const modelIds = useMemo(() => {
- return openAiModels.sort((a, b) => a.localeCompare(b))
- }, [openAiModels])
-
- const searchableItems = useMemo(() => {
- return modelIds.map((id) => ({
- id,
- html: id,
- }))
- }, [modelIds])
-
- const fzf = useMemo(() => {
- return new Fzf(searchableItems, {
- selector: (item) => item.html,
- })
- }, [searchableItems])
-
- const modelSearchResults = useMemo(() => {
- if (!searchTerm) return searchableItems
-
- const searchResults = fzf.find(searchTerm)
- return searchResults.map((result) => ({
- ...result.item,
- html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
- }))
- }, [searchableItems, searchTerm, fzf])
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (!isDropdownVisible) return
-
- switch (event.key) {
- case "ArrowDown":
- event.preventDefault()
- setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
- break
- case "ArrowUp":
- event.preventDefault()
- setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
- break
- case "Enter":
- event.preventDefault()
- if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
- handleModelChange(modelSearchResults[selectedIndex].id)
- setIsDropdownVisible(false)
- }
- break
- case "Escape":
- setIsDropdownVisible(false)
- setSelectedIndex(-1)
- break
- }
- }
-
- useEffect(() => {
- setSelectedIndex(-1)
- if (dropdownListRef.current) {
- dropdownListRef.current.scrollTop = 0
- }
- }, [searchTerm])
-
- useEffect(() => {
- if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
- itemRefs.current[selectedIndex]?.scrollIntoView({
- block: "nearest",
- behavior: "smooth",
- })
- }
- }, [selectedIndex])
+ const { apiConfiguration } = useExtensionState()
return (
- <>
-
-
-
- {
- handleModelChange((e.target as HTMLInputElement)?.value)
- setIsDropdownVisible(true)
- }}
- onFocus={() => setIsDropdownVisible(true)}
- onKeyDown={handleKeyDown}
- style={{ width: "100%", zIndex: OPENAI_MODEL_PICKER_Z_INDEX, position: "relative" }}>
- {searchTerm && (
- {
- handleModelChange("")
- setIsDropdownVisible(true)
- }}
- slot="end"
- style={{
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- height: "100%",
- }}
- />
- )}
-
- {isDropdownVisible && (
-
- {modelSearchResults.map((item, index) => (
- (itemRefs.current[index] = el)}
- onMouseEnter={() => setSelectedIndex(index)}
- onClick={() => {
- handleModelChange(item.id)
- setIsDropdownVisible(false)
- }}
- dangerouslySetInnerHTML={{
- __html: item.html,
- }}
- />
- ))}
-
- )}
-
-
- >
+
)
}
export default OpenAiModelPicker
-
-// Dropdown
-
-export const OPENAI_MODEL_PICKER_Z_INDEX = 1_000
diff --git a/webview-ui/src/components/settings/RequestyModelPicker.tsx b/webview-ui/src/components/settings/RequestyModelPicker.tsx
index bdc2db07448..e0759a43ba1 100644
--- a/webview-ui/src/components/settings/RequestyModelPicker.tsx
+++ b/webview-ui/src/components/settings/RequestyModelPicker.tsx
@@ -1,15 +1,22 @@
import { ModelPicker } from "./ModelPicker"
import { requestyDefaultModelId } from "../../../../src/shared/api"
+import { useExtensionState } from "@/context/ExtensionStateContext"
-export const RequestyModelPicker = () => (
-
-)
+export const RequestyModelPicker = () => {
+ const { apiConfiguration } = useExtensionState()
+ return (
+
+ )
+}
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx
index 6f4a196d2a8..3fec42f932a 100644
--- a/webview-ui/src/context/ExtensionStateContext.tsx
+++ b/webview-ui/src/context/ExtensionStateContext.tsx
@@ -151,7 +151,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentState.currentApiConfigName,
- apiConfiguration: apiConfig,
+ apiConfiguration: { ...currentState.apiConfiguration, ...apiConfig },
})
return currentState // No state update needed
})