Add openrouter model validation

This commit is contained in:
Saoud Rizwan
2024-10-04 02:40:58 -04:00
parent 4573010e16
commit fbd30281bc
4 changed files with 142 additions and 26 deletions

View File

@@ -36,9 +36,10 @@ import OpenRouterModelPicker, { ModelDescriptionMarkdown } from "./OpenRouterMod
interface ApiOptionsProps {
showModelOptions: boolean
apiErrorMessage?: string
modelIdErrorMessage?: string
}
const ApiOptions = ({ showModelOptions, apiErrorMessage }: ApiOptionsProps) => {
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState()
const [ollamaModels, setOllamaModels] = useState<string[]>([])
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
@@ -544,6 +545,17 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage }: ApiOptionsProps) => {
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} />
</>
)}
{modelIdErrorMessage && (
<p
style={{
margin: "-10px 0 4px 0",
fontSize: 12,
color: "var(--vscode-errorForeground)",
}}>
{modelIdErrorMessage}
</p>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import React, { memo, useEffect, useMemo, useRef, useState } from "react"
import React, { memo, useEffect, useMemo, useRef, useState, KeyboardEvent } from "react"
import { useRemark } from "react-remark"
import { useMount } from "react-use"
import styled from "styled-components"
@@ -8,12 +8,15 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
import { highlight } from "../history/HistoryView"
import { openRouterDefaultModelId } from "../../../../src/shared/api"
const OpenRouterModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState()
const [searchTerm, setSearchTerm] = useState("")
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const handleModelChange = (newModelId: string) => {
setApiConfiguration({
@@ -22,7 +25,6 @@ const OpenRouterModelPicker: React.FC = () => {
openRouterModelInfo: openRouterModels[newModelId],
})
setSearchTerm(newModelId)
setIsDropdownVisible(false)
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
@@ -46,15 +48,17 @@ const OpenRouterModelPicker: React.FC = () => {
}
}, [])
const searchableItems = useMemo(() => {
return Object.keys(openRouterModels)
.sort((a, b) => a.localeCompare(b))
.map((id) => ({
id,
html: id,
}))
const modelIds = useMemo(() => {
return Object.keys(openRouterModels).sort((a, b) => a.localeCompare(b))
}, [openRouterModels])
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
@@ -75,6 +79,49 @@ const OpenRouterModelPicker: React.FC = () => {
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)
}, [searchTerm])
useEffect(() => {
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
block: "nearest",
behavior: "smooth",
})
}
}, [selectedIndex])
return (
<>
<style>
@@ -94,16 +141,20 @@ const OpenRouterModelPicker: React.FC = () => {
placeholder="Search and select a model..."
value={searchTerm}
onInput={(e) => {
setSearchTerm((e.target as HTMLInputElement).value)
handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
setIsDropdownVisible(true)
}}
onFocus={() => setIsDropdownVisible(true)}
onKeyDown={handleKeyDown}
style={{ width: "100%" }}>
{searchTerm && (
<div
className="input-icon-button codicon codicon-close"
aria-label="Clear search"
onClick={() => setSearchTerm("")}
onClick={() => {
handleModelChange("")
setIsDropdownVisible(true)
}}
slot="end"
style={{
display: "flex",
@@ -116,10 +167,16 @@ const OpenRouterModelPicker: React.FC = () => {
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList>
{modelSearchResults.map((item) => (
{modelSearchResults.map((item, index) => (
<DropdownItem
key={item.id}
onClick={() => handleModelChange(item.id)}
ref={(el) => (itemRefs.current[index] = el)}
isSelected={index === selectedIndex}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
handleModelChange(item.id)
setIsDropdownVisible(false)
}}
dangerouslySetInnerHTML={{
__html: item.html,
}}
@@ -129,7 +186,22 @@ const OpenRouterModelPicker: React.FC = () => {
)}
</DropdownWrapper>
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} />
{hasInfo ? (
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} />
) : (
<p
style={{
fontSize: "12px",
marginTop: 0,
color: "var(--vscode-descriptionForeground)",
}}>
You can use{" "}
<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://openrouter.ai/models">
any model on OpenRouter
</VSCodeLink>{" "}
with Cline. (Try searching for "free" to see if there are free models currently available.)
</p>
)}
</>
)
}
@@ -157,9 +229,14 @@ const DropdownList = styled.div`
border-bottom-right-radius: 3px;
`
const DropdownItem = styled.div`
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

@@ -1,7 +1,7 @@
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration } from "../../utils/validate"
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import ApiOptions from "./ApiOptions"
@@ -19,15 +19,17 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setCustomInstructions,
alwaysAllowReadOnly,
setAlwaysAllowReadOnly,
openRouterModels,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
setApiErrorMessage(apiValidationResult)
if (!apiValidationResult) {
setModelIdErrorMessage(modelIdValidationResult)
if (!apiValidationResult && !modelIdValidationResult) {
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
vscode.postMessage({ type: "customInstructions", text: customInstructions })
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
@@ -37,6 +39,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
useEffect(() => {
setApiErrorMessage(undefined)
setModelIdErrorMessage(undefined)
}, [apiConfiguration])
// validate as soon as the component is mounted
@@ -82,7 +85,11 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
<div style={{ marginBottom: 5 }}>
<ApiOptions showModelOptions={true} apiErrorMessage={apiErrorMessage} />
<ApiOptions
showModelOptions={true}
apiErrorMessage={apiErrorMessage}
modelIdErrorMessage={modelIdErrorMessage}
/>
</div>
<div style={{ marginBottom: 5 }}>

View File

@@ -1,5 +1,5 @@
import { ApiConfiguration } from "../../../src/shared/api"
import { ModelInfo } from "../../../src/shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) {
switch (apiConfiguration.apiProvider) {
@@ -14,8 +14,8 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
}
break
case "openrouter":
if (!apiConfiguration.openRouterApiKey || !apiConfiguration.openRouterModelId) {
return "You must provide a valid API key and model ID or choose a different provider."
if (!apiConfiguration.openRouterApiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "vertex":
@@ -51,3 +51,23 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
}
return undefined
}
export function validateModelId(
apiConfiguration?: ApiConfiguration,
openRouterModels?: Record<string, ModelInfo>
): string | undefined {
if (apiConfiguration) {
switch (apiConfiguration.apiProvider) {
case "openrouter":
const modelId = apiConfiguration.openRouterModelId
if (!modelId) {
return "You must provide a model ID. If you're not sure which model to choose, Cline works best with Claude 3.5 Sonnet (anthropic/claude-3.5-sonnet)."
}
if (openRouterModels && !Object.keys(openRouterModels).includes(modelId)) {
return "The model ID you provided is not available. Please choose a different model."
}
break
}
}
return undefined
}