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 { interface ApiOptionsProps {
showModelOptions: boolean showModelOptions: boolean
apiErrorMessage?: string apiErrorMessage?: string
modelIdErrorMessage?: string
} }
const ApiOptions = ({ showModelOptions, apiErrorMessage }: ApiOptionsProps) => { const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState()
const [ollamaModels, setOllamaModels] = useState<string[]>([]) const [ollamaModels, setOllamaModels] = useState<string[]>([])
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
@@ -544,6 +545,17 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage }: ApiOptionsProps) => {
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} /> <ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} />
</> </>
)} )}
{modelIdErrorMessage && (
<p
style={{
margin: "-10px 0 4px 0",
fontSize: 12,
color: "var(--vscode-errorForeground)",
}}>
{modelIdErrorMessage}
</p>
)}
</div> </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 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 { useRemark } from "react-remark"
import { useMount } from "react-use" import { useMount } from "react-use"
import styled from "styled-components" import styled from "styled-components"
@@ -8,12 +8,15 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode" import { vscode } from "../../utils/vscode"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
import { highlight } from "../history/HistoryView" import { highlight } from "../history/HistoryView"
import { openRouterDefaultModelId } from "../../../../src/shared/api"
const OpenRouterModelPicker: React.FC = () => { const OpenRouterModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState() const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState()
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const handleModelChange = (newModelId: string) => { const handleModelChange = (newModelId: string) => {
setApiConfiguration({ setApiConfiguration({
@@ -22,7 +25,6 @@ const OpenRouterModelPicker: React.FC = () => {
openRouterModelInfo: openRouterModels[newModelId], openRouterModelInfo: openRouterModels[newModelId],
}) })
setSearchTerm(newModelId) setSearchTerm(newModelId)
setIsDropdownVisible(false)
} }
const { selectedModelId, selectedModelInfo } = useMemo(() => { const { selectedModelId, selectedModelInfo } = useMemo(() => {
@@ -46,14 +48,16 @@ const OpenRouterModelPicker: React.FC = () => {
} }
}, []) }, [])
const modelIds = useMemo(() => {
return Object.keys(openRouterModels).sort((a, b) => a.localeCompare(b))
}, [openRouterModels])
const searchableItems = useMemo(() => { const searchableItems = useMemo(() => {
return Object.keys(openRouterModels) return modelIds.map((id) => ({
.sort((a, b) => a.localeCompare(b))
.map((id) => ({
id, id,
html: id, html: id,
})) }))
}, [openRouterModels]) }, [modelIds])
const fuse = useMemo(() => { const fuse = useMemo(() => {
return new Fuse(searchableItems, { return new Fuse(searchableItems, {
@@ -75,6 +79,49 @@ const OpenRouterModelPicker: React.FC = () => {
return results return results
}, [searchableItems, searchTerm, fuse]) }, [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 ( return (
<> <>
<style> <style>
@@ -94,16 +141,20 @@ const OpenRouterModelPicker: React.FC = () => {
placeholder="Search and select a model..." placeholder="Search and select a model..."
value={searchTerm} value={searchTerm}
onInput={(e) => { onInput={(e) => {
setSearchTerm((e.target as HTMLInputElement).value) handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
setIsDropdownVisible(true) setIsDropdownVisible(true)
}} }}
onFocus={() => setIsDropdownVisible(true)} onFocus={() => setIsDropdownVisible(true)}
onKeyDown={handleKeyDown}
style={{ width: "100%" }}> style={{ width: "100%" }}>
{searchTerm && ( {searchTerm && (
<div <div
className="input-icon-button codicon codicon-close" className="input-icon-button codicon codicon-close"
aria-label="Clear search" aria-label="Clear search"
onClick={() => setSearchTerm("")} onClick={() => {
handleModelChange("")
setIsDropdownVisible(true)
}}
slot="end" slot="end"
style={{ style={{
display: "flex", display: "flex",
@@ -116,10 +167,16 @@ const OpenRouterModelPicker: React.FC = () => {
</VSCodeTextField> </VSCodeTextField>
{isDropdownVisible && ( {isDropdownVisible && (
<DropdownList> <DropdownList>
{modelSearchResults.map((item) => ( {modelSearchResults.map((item, index) => (
<DropdownItem <DropdownItem
key={item.id} 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={{ dangerouslySetInnerHTML={{
__html: item.html, __html: item.html,
}} }}
@@ -129,7 +186,22 @@ const OpenRouterModelPicker: React.FC = () => {
)} )}
</DropdownWrapper> </DropdownWrapper>
{hasInfo ? (
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} /> <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; border-bottom-right-radius: 3px;
` `
const DropdownItem = styled.div` const DropdownItem = styled.div<{ isSelected: boolean }>`
padding: 5px 10px; padding: 5px 10px;
cursor: pointer; cursor: pointer;
word-break: break-all;
white-space: normal;
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
&:hover { &:hover {
background-color: var(--vscode-list-activeSelectionBackground); background-color: var(--vscode-list-activeSelectionBackground);
} }

View File

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

View File

@@ -1,5 +1,5 @@
import { ApiConfiguration } from "../../../src/shared/api" import { ApiConfiguration } from "../../../src/shared/api"
import { ModelInfo } from "../../../src/shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined { export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) { if (apiConfiguration) {
switch (apiConfiguration.apiProvider) { switch (apiConfiguration.apiProvider) {
@@ -14,8 +14,8 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
} }
break break
case "openrouter": case "openrouter":
if (!apiConfiguration.openRouterApiKey || !apiConfiguration.openRouterModelId) { if (!apiConfiguration.openRouterApiKey) {
return "You must provide a valid API key and model ID or choose a different provider." return "You must provide a valid API key or choose a different provider."
} }
break break
case "vertex": case "vertex":
@@ -51,3 +51,23 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
} }
return undefined 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
}