Requesty dynamic model selection (#1836)

* Extract reuseable ModelDescriptionMarkdown from OpenRouter model picker

* Requesty: Add model picker component

* Refactor readOpenRouterModels to allow any dynamic list filename

* Extract parsePrice to allow reuse by other providers

* Simplify model display name switch case

* Requesty: Add dynamic model list fetching from API

* Requesty: Add default model selection

* Requesty: Specify max_tokens when sending request

* Add changeset

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
This commit is contained in:
Daniel Trugman 2025-02-28 21:29:34 +00:00 committed by GitHub
parent a02eb40c31
commit d1a097cbd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 612 additions and 211 deletions

View file

@ -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.

View file

@ -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 }
}
}

View file

@ -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<Record<string, ModelInfo> | undefined> {
const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
async readDynamicProviderModels(filename: string): Promise<Record<string, ModelInfo> | 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<string, ModelInfo> = {}
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<string | undefined>,
this.getSecret("requestyApiKey") as Promise<string | undefined>,
this.getGlobalState("requestyModelId") as Promise<string | undefined>,
this.getGlobalState("requestyModelInfo") as Promise<ModelInfo | undefined>,
this.getSecret("togetherApiKey") as Promise<string | undefined>,
this.getGlobalState("togetherModelId") as Promise<string | undefined>,
this.getSecret("qwenApiKey") as Promise<string | undefined>,
@ -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,

View file

@ -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<string, ModelInfo>
requestyModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
mcpMarketplaceCatalog?: McpMarketplaceCatalog

View file

@ -27,6 +27,7 @@ export interface WebviewMessage {
| "openMention"
| "cancelTask"
| "refreshOpenRouterModels"
| "refreshRequestyModels"
| "refreshOpenAiModels"
| "openMcpSettings"
| "restartMcpServer"

View file

@ -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<string, ModelInfo>

View file

@ -214,7 +214,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
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<any[]>([])
@ -635,14 +635,14 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// 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<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
case "litellm":
return `${selectedProvider}:${apiConfiguration.liteLlmModelId}`
case "requesty":
return `${selectedProvider}:${apiConfiguration.requestyModelId}`
case "anthropic":
case "openrouter":
default:
return `${selectedProvider}:${selectedModelId}`
}

View file

@ -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...">
<span style={{ fontWeight: 500 }}>API Key</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.requestyModelId || ""}
style={{ width: "100%" }}
onInput={handleInputChange("requestyModelId")}
placeholder={"Enter Model ID..."}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
<span style={{ color: "var(--vscode-errorForeground)" }}>
(<span style={{ fontWeight: 500 }}>Note:</span> Cline uses complex prompts and works best with Claude
models. Less capable models may not work as expected.)
</span>
</p>
{!apiConfiguration?.requestyApiKey && <a href="https://app.requesty.ai/manage-api">Get API Key</a>}
</div>
)}
@ -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 && <OpenRouterModelPicker isPopup={isPopup} />}
{selectedProvider === "requesty" && showModelOptions && <RequestyModelPicker isPopup={isPopup} />}
{modelIdErrorMessage && (
<p
@ -1412,6 +1401,12 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration):
selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo,
}
case "requesty":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
}
case "openai":
return {
selectedProvider: provider,

View file

@ -0,0 +1,138 @@
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useRef, useState } from "react"
import { useRemark } from "react-remark"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
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 [showSeeMore, setShowSeeMore] = useState(false)
const textContainerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(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 (
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
<div
ref={textContainerRef}
style={{
overflowY: isExpanded ? "auto" : "hidden",
position: "relative",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
<div
ref={textRef}
style={{
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}>
{reactContent}
</div>
{!isExpanded && showSeeMore && (
<div
style={{
position: "absolute",
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
}}>
<div
style={{
width: 30,
height: "1.2em",
background: "linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
}}
/>
<VSCodeLink
style={{
fontSize: "inherit",
paddingRight: 0,
paddingLeft: 3,
backgroundColor: isPopup ? CODE_BLOCK_BG_COLOR : "var(--vscode-sideBar-background)",
}}
onClick={() => setIsExpanded(true)}>
See more
</VSCodeLink>
</div>
)}
</div>
</StyledMarkdown>
)
},
)
export default ModelDescriptionMarkdown

View file

@ -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<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(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 (
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
<div
ref={textContainerRef}
style={{
overflowY: isExpanded ? "auto" : "hidden",
position: "relative",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
<div
ref={textRef}
style={{
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
// whiteSpace: "pre-wrap",
// wordBreak: "break-word",
// overflowWrap: "anywhere",
}}>
{reactContent}
</div>
{!isExpanded && showSeeMore && (
<div
style={{
position: "absolute",
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
}}>
<div
style={{
width: 30,
height: "1.2em",
background: "linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
}}
/>
<VSCodeLink
style={{
// cursor: "pointer",
// color: "var(--vscode-textLink-foreground)",
fontSize: "inherit",
paddingRight: 0,
paddingLeft: 3,
backgroundColor: isPopup ? CODE_BLOCK_BG_COLOR : "var(--vscode-sideBar-background)",
}}
onClick={() => setIsExpanded(true)}>
See more
</VSCodeLink>
</div>
)}
</div>
{/* {isExpanded && showSeeMore && (
<div
style={{
cursor: "pointer",
color: "var(--vscode-textLink-foreground)",
marginLeft: "auto",
textAlign: "right",
paddingRight: 2,
}}
onClick={() => setIsExpanded(false)}>
See less
</div>
)} */}
</StyledMarkdown>
)
},
)

View file

@ -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<RequestyModelPickerProps> = ({ 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<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const dropdownListRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div style={{ width: "100%" }}>
<style>
{`
.model-item-highlight {
background-color: var(--vscode-editor-findMatchHighlightBackground);
color: inherit;
}
`}
</style>
<div style={{ display: "flex", flexDirection: "column" }}>
<label htmlFor="model-search">
<span style={{ fontWeight: 500 }}>Model</span>
</label>
<DropdownWrapper ref={dropdownRef}>
<VSCodeTextField
id="model-search"
placeholder="Search and select a model..."
value={searchTerm}
onInput={(e) => {
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 && (
<div
className="input-icon-button codicon codicon-close"
aria-label="Clear search"
onClick={() => {
handleModelChange("")
setIsDropdownVisible(true)
}}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList ref={dropdownListRef}>
{modelSearchResults.map((item, index) => (
<DropdownItem
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
isSelected={index === selectedIndex}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
handleModelChange(item.id)
setIsDropdownVisible(false)
}}
dangerouslySetInnerHTML={{
__html: item.html,
}}
/>
))}
</DropdownList>
)}
</DropdownWrapper>
</div>
{hasInfo ? (
<ModelInfoView
selectedModelId={selectedModelId}
modelInfo={selectedModelInfo}
isDescriptionExpanded={isDescriptionExpanded}
setIsDescriptionExpanded={setIsDescriptionExpanded}
isPopup={isPopup}
/>
) : (
<p
style={{
fontSize: "12px",
marginTop: 0,
color: "var(--vscode-descriptionForeground)",
}}>
<>
The extension automatically fetches the latest list of models available on{" "}
<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://app.requesty.ai/router/list">
Requesty.
</VSCodeLink>
If you're unsure which model to choose, Cline works best with{" "}
<VSCodeLink
style={{ display: "inline", fontSize: "inherit" }}
onClick={() => handleModelChange("anthropic/claude-3-5-sonnet-latest")}>
anthropic/claude-3-5-sonnet-latest.
</VSCodeLink>
</>
</p>
)}
</div>
)
}
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);
}
`

View file

@ -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)

View file

@ -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<string, ModelInfo>
requestyModels: Record<string, ModelInfo>
openAiModels: string[]
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
@ -51,6 +59,9 @@ export const ExtensionStateContextProvider: React.FC<{
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
[requestyDefaultModelId]: requestyDefaultModelInfo,
})
const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
@ -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,

View file

@ -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<string, ModelInfo>,
requestyModels?: Record<string, ModelInfo>,
): 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."
}