diff --git a/.changeset/red-cars-cry.md b/.changeset/red-cars-cry.md new file mode 100644 index 0000000000..9bcc555431 --- /dev/null +++ b/.changeset/red-cars-cry.md @@ -0,0 +1,11 @@ +--- +"claude-dev": patch +--- + +Add dynamic model fetching for the Requesty provider. + +Instead of manually typing the model name, the extension dynamically fetches +all the supported model names from Requesty's /v1/models API. + +This allows users to use a fuzzy search logic when choosing the models and +also guarantees the information for each model is up to date. diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 2a4ed78321..c79e14bfe0 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -2,7 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { withRetry } from "../retry" import { calculateApiCostOpenAI } from "../../utils/cost" -import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api" +import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults, requestyDefaultModelId, requestyDefaultModelInfo } from "../../shared/api" import { ApiHandler } from "../index" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" @@ -25,7 +25,7 @@ export class RequestyHandler implements ApiHandler { @withRetry() async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const modelId = this.options.requestyModelId ?? "" + const model = this.getModel() let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, @@ -34,12 +34,13 @@ export class RequestyHandler implements ApiHandler { // @ts-ignore-next-line const stream = await this.client.chat.completions.create({ - model: modelId, + model: model.id, + max_tokens: model.info.maxTokens || undefined, messages: openAiMessages, temperature: 0, stream: true, stream_options: { include_usage: true }, - ...(modelId === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}), + ...(model.id === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}), }) for await (const chunk of stream) { @@ -89,9 +90,11 @@ export class RequestyHandler implements ApiHandler { } getModel(): { id: string; info: ModelInfo } { - return { - id: this.options.requestyModelId ?? "", - info: openAiModelInfoSaneDefaults, + const modelId = this.options.requestyModelId + const modelInfo = this.options.requestyModelInfo + if (modelId && modelInfo) { + return { id: modelId, info: modelInfo } } + return { id: requestyDefaultModelId, info: requestyDefaultModelInfo } } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a292f235af..f4632930b7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -95,6 +95,7 @@ type GlobalStateKey = | "liteLlmModelId" | "qwenApiLine" | "requestyModelId" + | "requestyModelInfo" | "togetherModelId" | "mcpMarketplaceCatalog" | "telemetrySetting" @@ -103,6 +104,7 @@ export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", openRouterModels: "openrouter_models.json", + requestyModels: "requesty_models.json", mcpSettings: "cline_mcp_settings.json", clineRules: ".clinerules", } @@ -497,7 +499,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { }), ) // post last cached models in case the call to endpoint fails - this.readOpenRouterModels().then((openRouterModels) => { + this.readDynamicProviderModels(GlobalFileNames.openRouterModels).then((openRouterModels) => { if (openRouterModels) { this.postMessageToWebview({ type: "openRouterModels", @@ -540,6 +542,34 @@ export class ClineProvider implements vscode.WebviewViewProvider { telemetryService.updateTelemetryState(isOptedIn) }) + + // post last cached models in case the call to endpoint fails + this.readDynamicProviderModels(GlobalFileNames.requestyModels).then((requestyModels) => { + if (requestyModels) { + this.postMessageToWebview({ + type: "requestyModels", + requestyModels, + }) + } + }) + + // gui relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch. + // we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point + // (see normalizeApiConfiguration > openrouter) + this.refreshRequestyModels().then(async (requestyModels) => { + if (requestyModels) { + // update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there) + const { apiConfiguration } = await this.getState() + if (apiConfiguration.requestyModelId) { + await this.updateGlobalState( + "requestyModelInfo", + requestyModels[apiConfiguration.requestyModelId], + ) + await this.postStateToWebview() + } + } + }) + break case "newTask": // Code that should run in response to the hello message command @@ -582,6 +612,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { deepSeekApiKey, requestyApiKey, requestyModelId, + requestyModelInfo, togetherApiKey, togetherModelId, qwenApiKey, @@ -635,6 +666,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("liteLlmModelId", liteLlmModelId) await this.updateGlobalState("qwenApiLine", qwenApiLine) await this.updateGlobalState("requestyModelId", requestyModelId) + await this.updateGlobalState("requestyModelInfo", requestyModelInfo) await this.updateGlobalState("togetherModelId", togetherModelId) if (this.cline) { this.cline.api = buildApiHandler(message.apiConfiguration) @@ -731,6 +763,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { case "refreshOpenRouterModels": await this.refreshOpenRouterModels() break + case "refreshRequestyModels": + await this.refreshRequestyModels() + break case "refreshOpenAiModels": const { apiConfiguration } = await this.getState() const openAiModels = await this.getOpenAiModels( @@ -996,6 +1031,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("previousModeModelId", apiConfiguration.openRouterModelId) await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openRouterModelInfo) break + case "requesty": + await this.updateGlobalState("previousModeModelId", apiConfiguration.requestyModelId) + await this.updateGlobalState("previousModeModelInfo", apiConfiguration.requestyModelInfo) + break case "vscode-lm": await this.updateGlobalState("previousModeModelId", apiConfiguration.vsCodeLmModelSelector) break @@ -1028,6 +1067,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("openRouterModelId", newModelId) await this.updateGlobalState("openRouterModelInfo", newModelInfo) break + case "requesty": + await this.updateGlobalState("requestyModelId", newModelId) + await this.updateGlobalState("requestyModelInfo", newModelInfo) + break case "vscode-lm": await this.updateGlobalState("vsCodeLmModelSelector", newModelId) break @@ -1500,16 +1543,61 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont return cacheDir } - async readOpenRouterModels(): Promise | undefined> { - const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels) - const fileExists = await fileExistsAtPath(openRouterModelsFilePath) + async readDynamicProviderModels(filename: string): Promise | undefined> { + const filePath = path.join(await this.ensureCacheDirectoryExists(), filename) + const fileExists = await fileExistsAtPath(filePath) if (fileExists) { - const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8") + const fileContents = await fs.readFile(filePath, "utf8") return JSON.parse(fileContents) } return undefined } + adjustPriceToMillionTokens(price: any) { + if (price) { + return parseFloat(price) * 1_000_000 + } + return undefined + } + + async refreshRequestyModels() { + const requestyModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.requestyModels) + + let models: Record = {} + try { + const response = await axios.get("https://router.requesty.ai/v1/models") + if (response.data?.data) { + for (const model of response.data.data) { + const modelInfo: ModelInfo = { + maxTokens: model.max_output_tokens, + contextWindow: model.context_window, + supportsImages: model.supports_images || undefined, + supportsComputerUse: model.supports_computer_use || undefined, + supportsPromptCache: model.supports_caching || undefined, + inputPrice: this.adjustPriceToMillionTokens(model.input_price), + outputPrice: this.adjustPriceToMillionTokens(model.output_price), + cacheWritesPrice: this.adjustPriceToMillionTokens(model.caching_price), + cacheReadsPrice: this.adjustPriceToMillionTokens(model.cached_price), + description: model.description, + } + models[model.id] = modelInfo + } + await fs.writeFile(requestyModelsFilePath, JSON.stringify(models)) + console.log("Requesty models fetched and saved", models) + } else { + console.error("Invalid response from Requesty API") + } + } catch (error) { + console.error("Error fetching Requesty models:", error) + } + + await this.postMessageToWebview({ + type: "requestyModels", + requestyModels: models, + }) + return models + } + async refreshOpenRouterModels() { const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels) @@ -1544,20 +1632,14 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont */ if (response.data?.data) { const rawModels = response.data.data - const parsePrice = (price: any) => { - if (price) { - return parseFloat(price) * 1_000_000 - } - return undefined - } for (const rawModel of rawModels) { const modelInfo: ModelInfo = { maxTokens: rawModel.top_provider?.max_completion_tokens, contextWindow: rawModel.context_length, supportsImages: rawModel.architecture?.modality?.includes("image"), supportsPromptCache: false, - inputPrice: parsePrice(rawModel.pricing?.prompt), - outputPrice: parsePrice(rawModel.pricing?.completion), + inputPrice: this.adjustPriceToMillionTokens(rawModel.pricing?.prompt), + outputPrice: this.adjustPriceToMillionTokens(rawModel.pricing?.completion), description: rawModel.description, } @@ -1858,6 +1940,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont deepSeekApiKey, requestyApiKey, requestyModelId, + requestyModelInfo, togetherApiKey, togetherModelId, qwenApiKey, @@ -1911,6 +1994,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont this.getSecret("deepSeekApiKey") as Promise, this.getSecret("requestyApiKey") as Promise, this.getGlobalState("requestyModelId") as Promise, + this.getGlobalState("requestyModelInfo") as Promise, this.getSecret("togetherApiKey") as Promise, this.getGlobalState("togetherModelId") as Promise, this.getSecret("qwenApiKey") as Promise, @@ -1987,6 +2071,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont deepSeekApiKey, requestyApiKey, requestyModelId, + requestyModelInfo, togetherApiKey, togetherModelId, qwenApiKey, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 8924e7adad..9b0ee7d17c 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -22,6 +22,7 @@ export interface ExtensionMessage { | "invoke" | "partialMessage" | "openRouterModels" + | "requestyModels" | "openAiModels" | "mcpServers" | "relinquishControl" @@ -51,6 +52,7 @@ export interface ExtensionMessage { filePaths?: string[] partialMessage?: ClineMessage openRouterModels?: Record + requestyModels?: Record openAiModels?: string[] mcpServers?: McpServer[] mcpMarketplaceCatalog?: McpMarketplaceCatalog diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 873a7390f9..2d5c88c5df 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -27,6 +27,7 @@ export interface WebviewMessage { | "openMention" | "cancelTask" | "refreshOpenRouterModels" + | "refreshRequestyModels" | "refreshOpenAiModels" | "openMcpSettings" | "restartMcpServer" diff --git a/src/shared/api.ts b/src/shared/api.ts index b6d5cfe4a2..f3de7e9360 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -49,6 +49,7 @@ export interface ApiHandlerOptions { deepSeekApiKey?: string requestyApiKey?: string requestyModelId?: string + requestyModelInfo?: ModelInfo togetherApiKey?: string togetherModelId?: string qwenApiKey?: string @@ -802,6 +803,22 @@ export const liteLlmModelInfoSaneDefaults: ModelInfo = { outputPrice: 0, } +// Requesty +// https://requesty.ai/models +export const requestyDefaultModelId = "anthropic/claude-3-5-sonnet-latest" +export const requestyDefaultModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsComputerUse: false, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + description: "Anthropic's most intelligent model. Highest level of intelligence and capability.", +} + // X AI // https://docs.x.ai/docs/api-reference export type XAIModelId = keyof typeof xaiModels @@ -880,3 +897,4 @@ export const xaiModels = { description: "X AI's Grok Beta model (legacy) with 131K context window", }, } as const satisfies Record + diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c45f88e256..ac1de348d0 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -214,7 +214,7 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, chatSettings, apiConfiguration, openRouterModels, platform } = useExtensionState() + const { filePaths, chatSettings, apiConfiguration, openRouterModels, requestyModels, platform } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [gitCommits, setGitCommits] = useState([]) @@ -635,14 +635,14 @@ const ChatTextArea = forwardRef( // Separate the API config submission logic const submitApiConfig = useCallback(() => { const apiValidationResult = validateApiConfiguration(apiConfiguration) - const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) + const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels, requestyModels) if (!apiValidationResult && !modelIdValidationResult) { vscode.postMessage({ type: "apiConfiguration", apiConfiguration }) } else { vscode.postMessage({ type: "getLatestState" }) } - }, [apiConfiguration, openRouterModels]) + }, [apiConfiguration, openRouterModels, requestyModels]) const onModeToggle = useCallback(() => { // if (textAreaDisabled) return @@ -742,9 +742,6 @@ const ChatTextArea = forwardRef( const unknownModel = "unknown" if (!apiConfiguration) return unknownModel switch (selectedProvider) { - case "anthropic": - case "openrouter": - return `${selectedProvider}:${selectedModelId}` case "openai": return `openai-compat:${selectedModelId}` case "vscode-lm": @@ -758,7 +755,8 @@ const ChatTextArea = forwardRef( case "litellm": return `${selectedProvider}:${apiConfiguration.liteLlmModelId}` case "requesty": - return `${selectedProvider}:${apiConfiguration.requestyModelId}` + case "anthropic": + case "openrouter": default: return `${selectedProvider}:${selectedModelId}` } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 5ab7543d4c..88c28159a1 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -31,6 +31,8 @@ import { openAiNativeModels, openRouterDefaultModelId, openRouterDefaultModelInfo, + requestyDefaultModelId, + requestyDefaultModelInfo, vertexDefaultModelId, vertexModels, xaiDefaultModelId, @@ -40,7 +42,9 @@ import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" import VSCodeButtonLink from "../common/VSCodeButtonLink" -import OpenRouterModelPicker, { ModelDescriptionMarkdown } from "./OpenRouterModelPicker" +import OpenRouterModelPicker from "./OpenRouterModelPicker" +import RequestyModelPicker from "./RequestyModelPicker" +import ModelDescriptionMarkdown from "./ModelDescriptionMarkdown" import styled from "styled-components" import * as vscodemodels from "vscode" import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "../../utils/vscStyles" @@ -53,7 +57,7 @@ interface ApiOptionsProps { } // This is necessary to ensure dropdown opens downward, important for when this is used in popup -const DROPDOWN_Z_INDEX = 1001 // Higher than the OpenRouterModelPicker's and ModelSelectorTooltip's z-index +const DROPDOWN_Z_INDEX = 1001 // Higher than the Requesty/OpenRouterModelPicker's and ModelSelectorTooltip's z-index const DropdownContainer = styled.div<{ zIndex?: number }>` position: relative; @@ -844,24 +848,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is placeholder="Enter API Key..."> API Key - - Model ID - -

- - (Note: Cline uses complex prompts and works best with Claude - models. Less capable models may not work as expected.) - -

+ {!apiConfiguration?.requestyApiKey && Get API Key} )} @@ -1177,6 +1164,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is )} {selectedProvider !== "openrouter" && + selectedProvider !== "requesty" && selectedProvider !== "openai" && selectedProvider !== "ollama" && selectedProvider !== "lmstudio" && @@ -1209,6 +1197,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is )} {selectedProvider === "openrouter" && showModelOptions && } + {selectedProvider === "requesty" && showModelOptions && } {modelIdErrorMessage && (

void + isPopup?: boolean + }) => { + const [reactContent, setMarkdown] = useRemark() + const [showSeeMore, setShowSeeMore] = useState(false) + const textContainerRef = useRef(null) + const textRef = useRef(null) + + useEffect(() => { + setMarkdown(markdown || "") + }, [markdown, setMarkdown]) + + useEffect(() => { + if (textRef.current && textContainerRef.current) { + const { scrollHeight } = textRef.current + const { clientHeight } = textContainerRef.current + const isOverflowing = scrollHeight > clientHeight + setShowSeeMore(isOverflowing) + } + }, [reactContent, setIsExpanded]) + + return ( + +

+
+ {reactContent} +
+ {!isExpanded && showSeeMore && ( +
+
+ setIsExpanded(true)}> + See more + +
+ )} +
+ + ) + }, +) + +export default ModelDescriptionMarkdown diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index 5f6020bbfa..15dd0cf3a5 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -1,7 +1,6 @@ import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import Fuse from "fuse.js" -import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" -import { useRemark } from "react-remark" +import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react" import { useMount } from "react-use" import styled from "styled-components" import { openRouterDefaultModelId } from "../../../../src/shared/api" @@ -9,7 +8,6 @@ import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" import { highlight } from "../history/HistoryView" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" -import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" export interface OpenRouterModelPickerProps { isPopup?: boolean @@ -276,158 +274,3 @@ const DropdownItem = styled.div<{ isSelected: boolean }>` background-color: var(--vscode-list-activeSelectionBackground); } ` - -// Markdown - -const StyledMarkdown = styled.div` - font-family: - var(--vscode-font-family), - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - Oxygen, - Ubuntu, - Cantarell, - "Open Sans", - "Helvetica Neue", - sans-serif; - font-size: 12px; - color: var(--vscode-descriptionForeground); - - p, - li, - ol, - ul { - line-height: 1.25; - margin: 0; - } - - ol, - ul { - padding-left: 1.5em; - margin-left: 0; - } - - p { - white-space: pre-wrap; - } - - a { - text-decoration: none; - } - a { - &:hover { - text-decoration: underline; - } - } -` - -export const ModelDescriptionMarkdown = memo( - ({ - markdown, - key, - isExpanded, - setIsExpanded, - isPopup, - }: { - markdown?: string - key: string - isExpanded: boolean - setIsExpanded: (isExpanded: boolean) => void - isPopup?: boolean - }) => { - const [reactContent, setMarkdown] = useRemark() - // const [isExpanded, setIsExpanded] = useState(false) - const [showSeeMore, setShowSeeMore] = useState(false) - const textContainerRef = useRef(null) - const textRef = useRef(null) - - useEffect(() => { - setMarkdown(markdown || "") - }, [markdown, setMarkdown]) - - useEffect(() => { - if (textRef.current && textContainerRef.current) { - const { scrollHeight } = textRef.current - const { clientHeight } = textContainerRef.current - const isOverflowing = scrollHeight > clientHeight - setShowSeeMore(isOverflowing) - // if (!isOverflowing) { - // setIsExpanded(false) - // } - } - }, [reactContent, setIsExpanded]) - - return ( - -
-
- {reactContent} -
- {!isExpanded && showSeeMore && ( -
-
- setIsExpanded(true)}> - See more - -
- )} -
- {/* {isExpanded && showSeeMore && ( -
setIsExpanded(false)}> - See less -
- )} */} - - ) - }, -) diff --git a/webview-ui/src/components/settings/RequestyModelPicker.tsx b/webview-ui/src/components/settings/RequestyModelPicker.tsx new file mode 100644 index 0000000000..ac72fa01c0 --- /dev/null +++ b/webview-ui/src/components/settings/RequestyModelPicker.tsx @@ -0,0 +1,274 @@ +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import Fuse from "fuse.js" +import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react" +import { useMount } from "react-use" +import styled from "styled-components" +import { requestyDefaultModelId } from "../../../../src/shared/api" +import { useExtensionState } from "../../context/ExtensionStateContext" +import { vscode } from "../../utils/vscode" +import { highlight } from "../history/HistoryView" +import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" + +export interface RequestyModelPickerProps { + isPopup?: boolean +} + +const RequestyModelPicker: React.FC = ({ isPopup }) => { + const { apiConfiguration, setApiConfiguration, requestyModels } = useExtensionState() + const [searchTerm, setSearchTerm] = useState(apiConfiguration?.requestyModelId || requestyDefaultModelId) + const [isDropdownVisible, setIsDropdownVisible] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) + const dropdownRef = useRef(null) + const itemRefs = useRef<(HTMLDivElement | null)[]>([]) + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) + const dropdownListRef = useRef(null) + + const handleModelChange = (newModelId: string) => { + // could be setting invalid model id/undefined info but validation will catch it + setApiConfiguration({ + ...apiConfiguration, + ...{ + requestyModelId: newModelId, + requestyModelInfo: requestyModels[newModelId], + }, + }) + setSearchTerm(newModelId) + } + + const { selectedModelId, selectedModelInfo } = useMemo(() => { + return normalizeApiConfiguration(apiConfiguration) + }, [apiConfiguration]) + + useMount(() => { + vscode.postMessage({ type: "refreshRequestyModels" }) + }) + + 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 Object.keys(requestyModels).sort((a, b) => a.localeCompare(b)) + }, [requestyModels]) + + const searchableItems = useMemo(() => { + return modelIds.map((id) => ({ + id, + html: id, + })) + }, [modelIds]) + + const fuse = useMemo(() => { + return new Fuse(searchableItems, { + keys: ["html"], // highlight function will update this + threshold: 0.6, + shouldSort: true, + isCaseSensitive: false, + ignoreLocation: false, + includeMatches: true, + minMatchCharLength: 1, + }) + }, [searchableItems]) + + const modelSearchResults = useMemo(() => { + let results: { id: string; html: string }[] = searchTerm + ? highlight(fuse.search(searchTerm), "model-item-highlight") + : searchableItems + return results + }, [searchableItems, searchTerm, fuse]) + + 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 + } + } + + const hasInfo = useMemo(() => { + return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase()) + }, [modelIds, searchTerm]) + + 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]) + + return ( +
+ +
+ + + { + handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase()) + setIsDropdownVisible(true) + }} + onFocus={() => setIsDropdownVisible(true)} + onKeyDown={handleKeyDown} + style={{ + width: "100%", + zIndex: REQUESTY_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)} + isSelected={index === selectedIndex} + onMouseEnter={() => setSelectedIndex(index)} + onClick={() => { + handleModelChange(item.id) + setIsDropdownVisible(false) + }} + dangerouslySetInnerHTML={{ + __html: item.html, + }} + /> + ))} + + )} + +
+ + {hasInfo ? ( + + ) : ( +

+ <> + The extension automatically fetches the latest list of models available on{" "} + + Requesty. + + If you're unsure which model to choose, Cline works best with{" "} + handleModelChange("anthropic/claude-3-5-sonnet-latest")}> + anthropic/claude-3-5-sonnet-latest. + + +

+ )} +
+ ) +} + +export default RequestyModelPicker + +// Dropdown + +const DropdownWrapper = styled.div` + position: relative; + width: 100%; +` + +export const REQUESTY_MODEL_PICKER_Z_INDEX = 1_000 + +const DropdownList = styled.div` + position: absolute; + top: calc(100% - 3px); + left: 0; + width: calc(100% - 2px); + max-height: 200px; + overflow-y: auto; + background-color: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-list-activeSelectionBackground); + z-index: ${REQUESTY_MODEL_PICKER_Z_INDEX - 1}; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +` + +const DropdownItem = styled.div<{ isSelected: boolean }>` + padding: 5px 10px; + cursor: pointer; + word-break: break-all; + white-space: normal; + + background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; + + &:hover { + background-color: var(--vscode-list-activeSelectionBackground); + } +` diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 144a598fd0..64ca59e61d 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -18,6 +18,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { customInstructions, setCustomInstructions, openRouterModels, + requestyModels, telemetrySetting, setTelemetrySetting, } = useExtensionState() @@ -26,7 +27,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) - const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) + const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels, requestyModels) setApiErrorMessage(apiValidationResult) setModelIdErrorMessage(modelIdValidationResult) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 58e90e3e84..fe3ca9e677 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -2,7 +2,14 @@ import React, { createContext, useCallback, useContext, useEffect, useState } fr import { useEvent } from "react-use" import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../../src/shared/AutoApprovalSettings" import { ExtensionMessage, ExtensionState, DEFAULT_PLATFORM } from "../../../src/shared/ExtensionMessage" -import { ApiConfiguration, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../../src/shared/api" +import { + ApiConfiguration, + ModelInfo, + openRouterDefaultModelId, + openRouterDefaultModelInfo, + requestyDefaultModelId, + requestyDefaultModelInfo, +} from "../../../src/shared/api" import { findLastIndex } from "../../../src/shared/array" import { McpMarketplaceCatalog, McpServer } from "../../../src/shared/mcp" import { convertTextMateToHljs } from "../utils/textMateToHljs" @@ -16,6 +23,7 @@ interface ExtensionStateContextType extends ExtensionState { showWelcome: boolean theme: any openRouterModels: Record + requestyModels: Record openAiModels: string[] mcpServers: McpServer[] mcpMarketplaceCatalog: McpMarketplaceCatalog @@ -51,6 +59,9 @@ export const ExtensionStateContextProvider: React.FC<{ const [openRouterModels, setOpenRouterModels] = useState>({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, }) + const [requestyModels, setRequestyModels] = useState>({ + [requestyDefaultModelId]: requestyDefaultModelInfo, + }) const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) @@ -65,6 +76,7 @@ export const ExtensionStateContextProvider: React.FC<{ ? [ config.apiKey, config.openRouterApiKey, + config.requestyApiKey, config.awsRegion, config.vertexProjectId, config.openAiApiKey, @@ -110,6 +122,14 @@ export const ExtensionStateContextProvider: React.FC<{ }) break } + case "requestyModels": { + const updatedModels = message.requestyModels ?? {} + setRequestyModels({ + [requestyDefaultModelId]: requestyDefaultModelInfo, // in case the extension sent a model list without the default model + ...updatedModels, + }) + break + } case "openRouterModels": { const updatedModels = message.openRouterModels ?? {} setOpenRouterModels({ @@ -148,6 +168,7 @@ export const ExtensionStateContextProvider: React.FC<{ showWelcome, theme, openRouterModels, + requestyModels, openAiModels, mcpServers, mcpMarketplaceCatalog, diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index a3d2e4106e..7fffe4a0f9 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -1,4 +1,4 @@ -import { ApiConfiguration, openRouterDefaultModelId } from "../../../src/shared/api" +import { ApiConfiguration, openRouterDefaultModelId, requestyDefaultModelId } from "../../../src/shared/api" import { ModelInfo } from "../../../src/shared/api" export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined { if (apiConfiguration) { @@ -91,15 +91,26 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s export function validateModelId( apiConfiguration?: ApiConfiguration, openRouterModels?: Record, + requestyModels?: Record, ): string | undefined { if (apiConfiguration) { switch (apiConfiguration.apiProvider) { case "openrouter": - const modelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default - if (!modelId) { + const openRouterModelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default + if (!openRouterModelId) { return "You must provide a model ID." } - if (openRouterModels && !Object.keys(openRouterModels).includes(modelId)) { + if (openRouterModels && !Object.keys(openRouterModels).includes(openRouterModelId)) { + // even if the model list endpoint failed, extensionstatecontext will always have the default model info + return "The model ID you provided is not available. Please choose a different model." + } + break + case "requesty": + const requestyModelId = apiConfiguration.requestyModelId || requestyDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default + if (!requestyModelId) { + return "You must provide a model ID." + } + if (requestyModels && !Object.keys(requestyModels).includes(requestyModelId)) { // even if the model list endpoint failed, extensionstatecontext will always have the default model info return "The model ID you provided is not available. Please choose a different model." }