Fetches all models from gateway during init

This commit is contained in:
Pugazhendhi 2025-02-03 12:18:15 +05:30
parent 29dfe11303
commit ea3d384e48
11 changed files with 11436 additions and 10881 deletions

View file

@ -1,7 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler, SingleCompletionHandler } from "../"
import { ApiHandlerOptions, ModelInfo, UnboundModelId, unboundDefaultModelId, unboundModels } from "../../shared/api"
import { ApiHandlerOptions, ModelInfo, unboundDefaultModelId, unboundDefaultModelInfo } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
@ -130,15 +130,15 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
}
}
getModel(): { id: UnboundModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in unboundModels) {
const id = modelId as UnboundModelId
return { id, info: unboundModels[id] }
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.unboundModelId
const modelInfo = this.options.unboundModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
}
return {
id: unboundDefaultModelId,
info: unboundModels[unboundDefaultModelId],
info: unboundDefaultModelInfo,
}
}

View file

@ -124,6 +124,7 @@ type GlobalStateKey =
| "autoApprovalEnabled"
| "customModes" // Array of custom modes
| "unboundModelId"
| "unboundModelInfo"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
@ -131,6 +132,7 @@ export const GlobalFileNames = {
glamaModels: "glama_models.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
unboundModels: "unbound_models.json",
}
export class ClineProvider implements vscode.WebviewViewProvider {
@ -529,6 +531,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
})
this.readUnboundModels().then((unboundModels) => {
if (unboundModels) {
this.postMessageToWebview({ type: "unboundModels", unboundModels })
}
})
this.refreshUnboundModels().then(async (unboundModels) => {
if (unboundModels) {
const { apiConfiguration } = await this.getState()
if (apiConfiguration?.unboundModelId) {
await this.updateGlobalState(
"unboundModelInfo",
unboundModels[apiConfiguration.unboundModelId],
)
await this.postStateToWebview()
}
}
})
this.configManager
.listConfig()
.then(async (listApiConfig) => {
@ -548,7 +568,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
let currentConfigName = (await this.getGlobalState("currentApiConfigName")) as string
const currentConfigName = (await this.getGlobalState("currentApiConfigName")) as string
if (currentConfigName) {
if (!(await this.configManager.hasConfig(currentConfigName))) {
@ -687,6 +707,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.postMessageToWebview({ type: "openAiModels", openAiModels })
}
break
case "refreshUnboundModels":
await this.refreshUnboundModels()
break
case "openImage":
openImage(message.text!)
break
@ -1124,7 +1147,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
if (message.text && message.apiConfiguration) {
try {
await this.configManager.saveConfig(message.text, message.apiConfiguration)
let listApiConfig = await this.configManager.listConfig()
const listApiConfig = await this.configManager.listConfig()
await Promise.all([
this.updateGlobalState("listApiConfigMeta", listApiConfig),
@ -1149,7 +1172,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.configManager.saveConfig(newName, message.apiConfiguration)
await this.configManager.deleteConfig(oldName)
let listApiConfig = await this.configManager.listConfig()
const listApiConfig = await this.configManager.listConfig()
const config = listApiConfig?.find((c) => c.name === newName)
// Update listApiConfigMeta first to ensure UI has latest data
@ -1207,7 +1230,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
// If this was the current config, switch to first available
let currentApiConfigName = await this.getGlobalState("currentApiConfigName")
const currentApiConfigName = await this.getGlobalState("currentApiConfigName")
if (message.text === currentApiConfigName && listApiConfig?.[0]?.name) {
const apiConfig = await this.configManager.loadConfig(listApiConfig[0].name)
await Promise.all([
@ -1227,7 +1250,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break
case "getListApiConfiguration":
try {
let listApiConfig = await this.configManager.listConfig()
const listApiConfig = await this.configManager.listConfig()
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
} catch (error) {
@ -1267,7 +1290,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine(
`Failed to update timeout for ${message.serverName}: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage(`Failed to update server timeout`)
vscode.window.showErrorMessage("Failed to update server timeout")
}
}
break
@ -1395,6 +1418,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mistralApiKey,
unboundApiKey,
unboundModelId,
unboundModelInfo,
} = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
@ -1435,6 +1459,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.storeSecret("mistralApiKey", mistralApiKey)
await this.storeSecret("unboundApiKey", unboundApiKey)
await this.updateGlobalState("unboundModelId", unboundModelId)
await this.updateGlobalState("unboundModelInfo", unboundModelInfo)
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}
@ -1620,7 +1645,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
async refreshGlamaModels() {
const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
let models: Record<string, ModelInfo> = {}
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://glama.ai/api/gateway/v1/models")
/*
@ -1710,7 +1735,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
GlobalFileNames.openRouterModels,
)
let models: Record<string, ModelInfo> = {}
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
/*
@ -1816,6 +1841,52 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return models
}
async readUnboundModels(): Promise<Record<string, ModelInfo> | undefined> {
const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
const fileExists = await fileExistsAtPath(unboundModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(unboundModelsFilePath, "utf8")
return JSON.parse(fileContents)
}
return undefined
}
async refreshUnboundModels() {
const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("http://localhost:8787/models")
if (response.data) {
const rawModels: Record<string, any> = response.data
for (const [modelId, model] of Object.entries(rawModels)) {
models[modelId] = {
maxTokens: model.maxTokens ? parseInt(model.maxTokens) : undefined,
contextWindow: model.contextWindow ? parseInt(model.contextWindow) : 0,
supportsImages: model.supportsImages ?? false,
supportsPromptCache: model.supportsPromptCaching ?? false,
supportsComputerUse: model.supportsComputerUse ?? false,
inputPrice: model.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined,
outputPrice: model.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined,
cacheWritesPrice: model.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined,
cacheReadsPrice: model.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined,
}
}
}
await fs.writeFile(unboundModelsFilePath, JSON.stringify(models))
this.outputChannel.appendLine(`Unbound models fetched and saved: ${JSON.stringify(models, null, 2)}`)
} catch (error) {
this.outputChannel.appendLine(
`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
}
await this.postMessageToWebview({ type: "unboundModels", unboundModels: models })
return models
}
// Task history
async getTaskWithId(id: string): Promise<{
@ -2104,6 +2175,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
experiments,
unboundApiKey,
unboundModelId,
unboundModelInfo,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@ -2176,6 +2248,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("experiments") as Promise<Record<ExperimentId, boolean> | undefined>,
this.getSecret("unboundApiKey") as Promise<string | undefined>,
this.getGlobalState("unboundModelId") as Promise<string | undefined>,
this.getGlobalState("unboundModelInfo") as Promise<ModelInfo | undefined>,
])
let apiProvider: ApiProvider
@ -2233,6 +2306,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
vsCodeLmModelSelector,
unboundApiKey,
unboundModelId,
unboundModelInfo,
},
lastShownAnnouncementId,
customInstructions,

View file

@ -42,6 +42,8 @@ export interface ExtensionMessage {
| "autoApprovalEnabled"
| "updateCustomMode"
| "deleteCustomMode"
| "unboundModels"
| "refreshUnboundModels"
text?: string
action?:
| "chatButtonClicked"
@ -61,6 +63,7 @@ export interface ExtensionMessage {
glamaModels?: Record<string, ModelInfo>
openRouterModels?: Record<string, ModelInfo>
openAiModels?: string[]
unboundModels?: Record<string, ModelInfo>
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[]

View file

@ -39,6 +39,7 @@ export interface WebviewMessage {
| "refreshGlamaModels"
| "refreshOpenRouterModels"
| "refreshOpenAiModels"
| "refreshUnboundModels"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"

View file

@ -60,6 +60,7 @@ export interface ApiHandlerOptions {
includeMaxTokens?: boolean
unboundApiKey?: string
unboundModelId?: string
unboundModelInfo?: ModelInfo
}
export type ApiConfiguration = ApiHandlerOptions & {
@ -564,7 +565,7 @@ export const deepSeekModels = {
supportsPromptCache: false,
inputPrice: 0.014, // $0.014 per million tokens
outputPrice: 0.28, // $0.28 per million tokens
description: `DeepSeek-V3 achieves a significant breakthrough in inference speed over previous models. It tops the leaderboard among open-source models and rivals the most advanced closed-source models globally.`,
description: "DeepSeek-V3 achieves a significant breakthrough in inference speed over previous models. It tops the leaderboard among open-source models and rivals the most advanced closed-source models globally.",
},
"deepseek-reasoner": {
maxTokens: 8192,
@ -573,7 +574,7 @@ export const deepSeekModels = {
supportsPromptCache: false,
inputPrice: 0.55, // $0.55 per million tokens
outputPrice: 2.19, // $2.19 per million tokens
description: `DeepSeek-R1 achieves performance comparable to OpenAI-o1 across math, code, and reasoning tasks.`,
description: "DeepSeek-R1 achieves performance comparable to OpenAI-o1 across math, code, and reasoning tasks.",
},
} as const satisfies Record<string, ModelInfo>
@ -598,12 +599,20 @@ export const mistralModels = {
} as const satisfies Record<string, ModelInfo>
// Unbound Security
export type UnboundModelId = keyof typeof unboundModels
// export type UnboundModelId = keyof typeof unboundModels
export const unboundDefaultModelId = "openai/gpt-4o"
export const unboundModels = {
"anthropic/claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
"openai/gpt-4o": openAiNativeModels["gpt-4o"],
"deepseek/deepseek-chat": deepSeekModels["deepseek-chat"],
"deepseek/deepseek-reasoner": deepSeekModels["deepseek-reasoner"],
"mistral/codestral-latest": mistralModels["codestral-latest"],
} as const satisfies Record<string, ModelInfo>
// export const unboundModels = {
// "anthropic/claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
// "openai/gpt-4o": openAiNativeModels["gpt-4o"],
// "deepseek/deepseek-chat": deepSeekModels["deepseek-chat"],
// "deepseek/deepseek-reasoner": deepSeekModels["deepseek-reasoner"],
// "mistral/codestral-latest": mistralModels["codestral-latest"],
// } as const satisfies Record<string, ModelInfo>
export const unboundDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
}

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@ describe("AutoApproveMenu", () => {
glamaModels: {},
openRouterModels: {},
openAiModels: [],
unboundModels: {},
mcpServers: [],
filePaths: [],
experiments: experimentDefault,

View file

@ -27,7 +27,7 @@ import {
vertexDefaultModelId,
vertexModels,
unboundDefaultModelId,
unboundModels,
unboundDefaultModelInfo,
} from "../../../../src/shared/api"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
@ -40,6 +40,7 @@ import OpenRouterModelPicker, {
} from "./OpenRouterModelPicker"
import OpenAiModelPicker from "./OpenAiModelPicker"
import GlamaModelPicker from "./GlamaModelPicker"
import UnboundModelPicker from "./UnboundModelPicker"
interface ApiOptionsProps {
apiErrorMessage?: string
@ -55,6 +56,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({})
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
@ -89,6 +91,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
setLmStudioModels(message.lmStudioModels)
} else if (message.type === "vsCodeLmModels" && message.vsCodeLmModels) {
setVsCodeLmModels(message.vsCodeLmModels)
} else if (message.type === "unboundModels" && message.unboundModels) {
setUnboundModels(message.unboundModels)
}
}, [])
useEvent("message", handleMessage)
@ -1312,6 +1316,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
}}>
This key is stored locally and only used to make API requests from this extension.
</p>
<UnboundModelPicker />
</div>
)}
@ -1334,7 +1339,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
selectedProvider !== "openrouter" &&
selectedProvider !== "openai" &&
selectedProvider !== "ollama" &&
selectedProvider !== "lmstudio" && (
selectedProvider !== "lmstudio" &&
selectedProvider !== "unbound" && (
<>
<div className="dropdown-container">
<label htmlFor="model-id">
@ -1347,7 +1353,6 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
{selectedProvider === "mistral" && createDropdown(mistralModels)}
{selectedProvider === "unbound" && createDropdown(unboundModels)}
</div>
<ModelInfoView
@ -1586,7 +1591,11 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
},
}
case "unbound":
return getProviderData(unboundModels, unboundDefaultModelId)
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
}
default:
return getProviderData(anthropicModels, anthropicDefaultModelId)
}

View file

@ -0,0 +1,430 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { Fzf } from "fzf"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
import { useMount } from "react-use"
import styled from "styled-components"
import { unboundDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlightFzfMatch } from "../../utils/highlight"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
const UnboundModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, unboundModels, onUpdateApiConfig } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.apiModelId || unboundDefaultModelId)
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) => {
const apiConfig = {
...apiConfiguration,
unboundModelId: newModelId,
unboundModelInfo: unboundModels[newModelId],
}
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setSearchTerm(newModelId)
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
useEffect(() => {
if (apiConfiguration?.unboundModelId && apiConfiguration?.unboundModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.unboundModelId)
}
}, [apiConfiguration, searchTerm])
const debouncedRefreshModels = useMemo(
() =>
debounce(() => {
vscode.postMessage({ type: "refreshUnboundModels" })
}, 50),
[],
)
useMount(() => {
debouncedRefreshModels()
return () => {
debouncedRefreshModels.clear()
}
})
useEffect(() => {
if (apiConfiguration?.unboundApiKey) {
debouncedRefreshModels()
}
}, [apiConfiguration?.unboundApiKey])
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(unboundModels).sort((a, b) => a.localeCompare(b))
}, [unboundModels])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
id,
html: id,
}))
}, [modelIds])
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: (item) => item.html,
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map((result) => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
}))
}, [searchableItems, searchTerm, fzf])
const handleKeyDown = (event: KeyboardEvent<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 (
<>
<style>
{`
.model-item-highlight {
background-color: var(--vscode-editor-findMatchHighlightBackground);
color: inherit;
}
`}
</style>
<div>
<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) => {
const value = (e.target as HTMLInputElement)?.value
setSearchTerm(value)
setIsDropdownVisible(true)
}}
onFocus={() => setIsDropdownVisible(true)}
onKeyDown={handleKeyDown}
style={{ width: "100%", zIndex: UNBOUND_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}
/>
) : (
<p
style={{
fontSize: "12px",
marginTop: 0,
color: "var(--vscode-descriptionForeground)",
}}>
The extension automatically fetches the latest list of models available from your Unbound instance.
</p>
)}
</>
)
}
export default UnboundModelPicker
// Dropdown
const DropdownWrapper = styled.div`
position: relative;
width: 100%;
`
export const UNBOUND_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: ${UNBOUND_MODEL_PICKER_Z_INDEX - 1};
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
`
const DropdownItem = styled.div<{ isSelected?: boolean }>`
padding: 4px 8px;
cursor: pointer;
background-color: ${(props) => (props.isSelected ? "var(--vscode-list-activeSelectionBackground)" : "transparent")};
color: ${(props) =>
props.isSelected ? "var(--vscode-list-activeSelectionForeground)" : "var(--vscode-foreground)"};
&:hover {
background-color: var(--vscode-list-hoverBackground);
}
`
// 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,
}: {
markdown?: string
key: string
isExpanded: boolean
setIsExpanded: (isExpanded: boolean) => void
}) => {
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: "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

@ -8,6 +8,8 @@ import {
glamaDefaultModelInfo,
openRouterDefaultModelId,
openRouterDefaultModelInfo,
unboundDefaultModelId,
unboundDefaultModelInfo,
} from "../../../src/shared/api"
import { vscode } from "../utils/vscode"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
@ -24,6 +26,7 @@ export interface ExtensionStateContextType extends ExtensionState {
theme: any
glamaModels: Record<string, ModelInfo>
openRouterModels: Record<string, ModelInfo>
unboundModels: Record<string, ModelInfo>
openAiModels: string[]
mcpServers: McpServer[]
filePaths: string[]
@ -113,6 +116,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
[unboundDefaultModelId]: unboundDefaultModelInfo,
})
const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
@ -208,6 +214,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setOpenAiModels(updatedModels)
break
}
case "unboundModels": {
const updatedModels = message.unboundModels ?? {}
setUnboundModels(updatedModels)
break
}
case "mcpServers": {
setMcpServers(message.mcpServers ?? [])
break
@ -235,6 +246,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
glamaModels,
openRouterModels,
openAiModels,
unboundModels,
mcpServers,
filePaths,
soundVolume: state.soundVolume,

View file

@ -1,4 +1,9 @@
import { ApiConfiguration, glamaDefaultModelId, openRouterDefaultModelId } from "../../../src/shared/api"
import {
ApiConfiguration,
glamaDefaultModelId,
openRouterDefaultModelId,
unboundDefaultModelId,
} from "../../../src/shared/api"
import { ModelInfo } from "../../../src/shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) {
@ -76,6 +81,7 @@ export function validateModelId(
apiConfiguration?: ApiConfiguration,
glamaModels?: Record<string, ModelInfo>,
openRouterModels?: Record<string, ModelInfo>,
unboundModels?: Record<string, ModelInfo>,
): string | undefined {
if (apiConfiguration) {
switch (apiConfiguration.apiProvider) {
@ -99,6 +105,16 @@ export function validateModelId(
return "The model ID you provided is not available. Please choose a different model."
}
break
case "unbound":
const unboundModelId = apiConfiguration.unboundModelId || unboundDefaultModelId
if (!unboundModelId) {
return "You must provide a model ID."
}
if (unboundModels && !Object.keys(unboundModels).includes(unboundModelId)) {
// 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
}
}
return undefined