mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22898a970b | |||
| 82788e1b9a | |||
| 2588ec34d8 |
@@ -39,6 +39,8 @@ service ModelsService {
|
||||
rpc refreshGroqModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns Baseten models
|
||||
rpc refreshBasetenModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns LiteLLM models
|
||||
rpc refreshLiteLlmModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Fetches available models from SAP AI Core
|
||||
rpc getSapAiCoreModels(SapAiCoreModelsRequest) returns (SapAiCoreModelsResponse);
|
||||
// Fetches available models from OCA
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import axios, { AxiosError } from "axios"
|
||||
|
||||
export interface FetchOpenAiCompatibleModelsOptions {
|
||||
baseUrl: string
|
||||
headers?: Record<string, string | undefined>
|
||||
transform?: (model: any) => Partial<ModelInfo>
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a base URL to ensure it ends with /v1
|
||||
*/
|
||||
export function normalizeOpenAiCompatibleBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "")
|
||||
if (trimmed.endsWith("/v1")) {
|
||||
return trimmed
|
||||
}
|
||||
return `${trimmed}/v1`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches models from an OpenAI-compatible /v1/models endpoint and maps them to ModelInfo objects.
|
||||
*/
|
||||
export async function fetchOpenAiCompatibleModels({
|
||||
baseUrl,
|
||||
headers = {},
|
||||
transform,
|
||||
}: FetchOpenAiCompatibleModelsOptions): Promise<Record<string, ModelInfo>> {
|
||||
const normalizedBaseUrl = normalizeOpenAiCompatibleBaseUrl(baseUrl || "http://localhost:4000")
|
||||
const url = `${normalizedBaseUrl}/models`
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
headers: Object.fromEntries(Object.entries(headers).filter(([_, value]) => Boolean(value))),
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const rawModels = Array.isArray(response.data?.data) ? response.data.data : []
|
||||
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
for (const rawModel of rawModels) {
|
||||
if (!rawModel || typeof rawModel.id !== "string") {
|
||||
continue
|
||||
}
|
||||
|
||||
const transformed = transform ? transform(rawModel) : {}
|
||||
models[rawModel.id] = {
|
||||
supportsPromptCache: false,
|
||||
description:
|
||||
transformed.description ??
|
||||
rawModel.description ??
|
||||
(rawModel.owned_by ? `Provided by ${rawModel.owned_by}` : undefined),
|
||||
maxTokens: transformed.maxTokens,
|
||||
contextWindow: transformed.contextWindow,
|
||||
supportsImages: transformed.supportsImages,
|
||||
inputPrice: transformed.inputPrice,
|
||||
outputPrice: transformed.outputPrice,
|
||||
cacheWritesPrice: transformed.cacheWritesPrice,
|
||||
cacheReadsPrice: transformed.cacheReadsPrice,
|
||||
thinkingConfig: transformed.thinkingConfig,
|
||||
tiers: transformed.tiers,
|
||||
supportsGlobalEndpoint: transformed.supportsGlobalEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
return models
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError
|
||||
throw new Error(
|
||||
`Failed to fetch models from ${url}: ${axiosError.response?.status} ${axiosError.response?.statusText || axiosError.message}`,
|
||||
)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { liteLlmModelInfoSaneDefaults, ModelInfo } from "@shared/api"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import {
|
||||
fetchOpenAiCompatibleModels,
|
||||
normalizeOpenAiCompatibleBaseUrl,
|
||||
} from "@/core/api/providers/shared/fetchOpenAiCompatibleModels"
|
||||
import { Controller } from ".."
|
||||
|
||||
type LiteLlmModelResponseEntry = NonNullable<LiteLlmModelInfoResponse["data"]>[number]
|
||||
|
||||
interface LiteLlmModelInfoResponse {
|
||||
data?: Array<{
|
||||
model_name?: string
|
||||
description?: string
|
||||
litellm_params?: {
|
||||
model?: string
|
||||
max_tokens?: number
|
||||
context_window?: number
|
||||
supports_images?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
model_info?: {
|
||||
input_cost_per_token?: number | string
|
||||
output_cost_per_token?: number | string
|
||||
cache_creation_input_token_cost?: number | string
|
||||
cache_read_input_token_cost?: number | string
|
||||
supports_prompt_caching?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts LiteLLM cost values (per-token) into price per million tokens.
|
||||
*/
|
||||
function convertCostToPerMillion(value?: number | string): number | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined
|
||||
}
|
||||
const numericValue = typeof value === "number" ? value : parseFloat(value)
|
||||
if (Number.isNaN(numericValue)) {
|
||||
return undefined
|
||||
}
|
||||
return numericValue * 1_000_000
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the LiteLLM models and returns application types.
|
||||
* @param controller The controller instance
|
||||
* @returns Record of model ID to ModelInfo (application types)
|
||||
*/
|
||||
export async function refreshLiteLlmModels(controller: Controller): Promise<Record<string, ModelInfo>> {
|
||||
const liteLlmModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.liteLlmModels)
|
||||
|
||||
const liteLlmApiKey = controller.stateManager.getSecretKey("liteLlmApiKey")
|
||||
const rawBaseUrl = controller.stateManager.getGlobalSettingsKey("liteLlmBaseUrl")
|
||||
const normalizedBaseUrl = getNormalizedLiteLlmBaseUrl(rawBaseUrl)
|
||||
|
||||
let models: Record<string, ModelInfo> = {}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string | undefined> = {
|
||||
"x-litellm-api-key": liteLlmApiKey,
|
||||
Authorization: liteLlmApiKey ? `Bearer ${liteLlmApiKey}` : undefined,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Cline-VSCode-Extension",
|
||||
}
|
||||
|
||||
const transformedModels = await fetchOpenAiCompatibleModels({
|
||||
baseUrl: normalizedBaseUrl,
|
||||
headers,
|
||||
transform: (rawModel: LiteLlmModelResponseEntry): Partial<ModelInfo> => {
|
||||
const modelInfo = rawModel.model_info ?? {}
|
||||
const params = rawModel.litellm_params ?? {}
|
||||
|
||||
const partial: Partial<ModelInfo> = {}
|
||||
|
||||
if (typeof params.max_tokens === "number") {
|
||||
partial.maxTokens = params.max_tokens
|
||||
}
|
||||
if (typeof params.context_window === "number") {
|
||||
partial.contextWindow = params.context_window
|
||||
}
|
||||
if (typeof params.supports_images === "boolean") {
|
||||
partial.supportsImages = params.supports_images
|
||||
}
|
||||
if (typeof modelInfo.supports_prompt_caching === "boolean") {
|
||||
partial.supportsPromptCache = modelInfo.supports_prompt_caching
|
||||
}
|
||||
|
||||
const inputCost = convertCostToPerMillion(modelInfo.input_cost_per_token)
|
||||
if (inputCost !== undefined) {
|
||||
partial.inputPrice = inputCost
|
||||
}
|
||||
|
||||
const outputCost = convertCostToPerMillion(modelInfo.output_cost_per_token)
|
||||
if (outputCost !== undefined) {
|
||||
partial.outputPrice = outputCost
|
||||
}
|
||||
|
||||
const cacheWriteCost = convertCostToPerMillion(modelInfo.cache_creation_input_token_cost)
|
||||
if (cacheWriteCost !== undefined) {
|
||||
partial.cacheWritesPrice = cacheWriteCost
|
||||
}
|
||||
|
||||
const cacheReadCost = convertCostToPerMillion(modelInfo.cache_read_input_token_cost)
|
||||
if (cacheReadCost !== undefined) {
|
||||
partial.cacheReadsPrice = cacheReadCost
|
||||
}
|
||||
|
||||
if (rawModel.description) {
|
||||
partial.description = rawModel.description
|
||||
}
|
||||
|
||||
return partial
|
||||
},
|
||||
})
|
||||
|
||||
if (Object.keys(transformedModels).length > 0) {
|
||||
models = Object.fromEntries(
|
||||
Object.entries(transformedModels).map(([modelId, modelInfo]) => [
|
||||
modelId,
|
||||
{
|
||||
...liteLlmModelInfoSaneDefaults,
|
||||
...modelInfo,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
await fs.writeFile(liteLlmModelsFilePath, JSON.stringify(models))
|
||||
} else {
|
||||
console.warn("LiteLLM model list was empty; retaining previous cache if available.")
|
||||
const cachedModels = await readLiteLlmModels()
|
||||
if (cachedModels) {
|
||||
models = cachedModels
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching LiteLLM models:", error)
|
||||
const cachedModels = await readLiteLlmModels()
|
||||
if (cachedModels) {
|
||||
models = cachedModels
|
||||
}
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads cached LiteLLM models from disk (application types)
|
||||
*/
|
||||
async function readLiteLlmModels(): Promise<Record<string, ModelInfo> | undefined> {
|
||||
const liteLlmModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.liteLlmModels)
|
||||
if (await fileExistsAtPath(liteLlmModelsFilePath)) {
|
||||
try {
|
||||
const fileContents = await fs.readFile(liteLlmModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents) as Record<string, ModelInfo>
|
||||
} catch (error) {
|
||||
console.error("Error reading cached LiteLLM models:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to resolve the effective LiteLLM base URL with default normalization.
|
||||
*/
|
||||
export function getNormalizedLiteLlmBaseUrl(baseUrl: string | undefined): string {
|
||||
return normalizeOpenAiCompatibleBaseUrl(baseUrl || "http://localhost:4000")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
|
||||
import { toProtobufModels } from "@/shared/proto-conversions/models/typeConversion"
|
||||
import { Controller } from ".."
|
||||
import { refreshLiteLlmModels } from "./refreshLiteLlmModels"
|
||||
|
||||
/**
|
||||
* Handles protobuf conversion for gRPC service
|
||||
* @param controller The controller instance
|
||||
* @param _request Empty request object
|
||||
* @returns Response containing LiteLLM models (protobuf types)
|
||||
*/
|
||||
export async function refreshLiteLlmModelsRpc(
|
||||
controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
): Promise<OpenRouterCompatibleModelInfo> {
|
||||
const models = await refreshLiteLlmModels(controller)
|
||||
return OpenRouterCompatibleModelInfo.create({ models: toProtobufModels(models) })
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const GlobalFileNames = {
|
||||
vercelAiGatewayModels: "vercel_ai_gateway_models.json",
|
||||
groqModels: "groq_models.json",
|
||||
basetenModels: "baseten_models.json",
|
||||
liteLlmModels: "litellm_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
workflows: ".clinerules/workflows",
|
||||
|
||||
+1
-1
@@ -2441,7 +2441,7 @@ export const liteLlmModelInfoSaneDefaults: LiteLLMModelInfo = {
|
||||
outputPrice: 0,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
temperature: 0,
|
||||
temperature: 1,
|
||||
}
|
||||
|
||||
// AskSage Models
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults } from "@shared/api"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { fromProtobufModels } from "@shared/proto-conversions/models/typeConversion"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
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 { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { ModelsServiceClient } from "../../services/grpc-client"
|
||||
import { highlight } from "../history/HistoryView"
|
||||
import { ModelInfoView } from "./common/ModelInfoView"
|
||||
import { getModeSpecificFields, normalizeApiConfiguration } from "./utils/providerUtils"
|
||||
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
|
||||
|
||||
export interface LiteLlmModelPickerProps {
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
}
|
||||
|
||||
const LiteLlmModelPicker: React.FC<LiteLlmModelPickerProps> = ({ isPopup, currentMode }) => {
|
||||
const { apiConfiguration, liteLlmModels: dynamicLiteLlmModels, setLiteLlmModels } = useExtensionState()
|
||||
const { handleModeFieldsChange } = useApiConfigurationHandlers()
|
||||
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
|
||||
const [searchTerm, setSearchTerm] = useState(modeFields.liteLlmModelId || liteLlmDefaultModelId)
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm)
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleModelChange = (newModelId: string) => {
|
||||
const modelInfo = dynamicLiteLlmModels?.[newModelId] || liteLlmModelInfoSaneDefaults
|
||||
|
||||
handleModeFieldsChange(
|
||||
{
|
||||
liteLlmModelId: { plan: "planModeLiteLlmModelId", act: "actModeLiteLlmModelId" },
|
||||
liteLlmModelInfo: { plan: "planModeLiteLlmModelInfo", act: "actModeLiteLlmModelInfo" },
|
||||
},
|
||||
{
|
||||
liteLlmModelId: newModelId,
|
||||
liteLlmModelInfo: modelInfo,
|
||||
},
|
||||
currentMode,
|
||||
)
|
||||
setSearchTerm(newModelId)
|
||||
}
|
||||
|
||||
const { selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||
return normalizeApiConfiguration(apiConfiguration, currentMode)
|
||||
}, [apiConfiguration, currentMode])
|
||||
|
||||
useMount(() => {
|
||||
ModelsServiceClient.refreshLiteLlmModelsRpc(EmptyRequest.create({}))
|
||||
.then((response) => {
|
||||
setLiteLlmModels(fromProtobufModels(response.models))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to refresh LiteLLM models:", err)
|
||||
})
|
||||
})
|
||||
|
||||
// Sync external changes when the modelId changes
|
||||
useEffect(() => {
|
||||
const currentModelId = modeFields.liteLlmModelId || liteLlmDefaultModelId
|
||||
setSearchTerm(currentModelId)
|
||||
}, [modeFields.liteLlmModelId])
|
||||
|
||||
// Debounce search term to reduce re-renders
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm)
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchTerm])
|
||||
|
||||
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 allLiteLlmModels = dynamicLiteLlmModels || {}
|
||||
|
||||
const modelIds = useMemo(() => {
|
||||
return Object.keys(allLiteLlmModels).sort((a, b) => a.localeCompare(b))
|
||||
}, [allLiteLlmModels])
|
||||
|
||||
const searchableItems = useMemo(() => {
|
||||
return modelIds.map((id) => ({
|
||||
id,
|
||||
html: id,
|
||||
}))
|
||||
}, [modelIds])
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(searchableItems, {
|
||||
keys: ["html"],
|
||||
threshold: 0.6,
|
||||
shouldSort: true,
|
||||
isCaseSensitive: false,
|
||||
ignoreLocation: false,
|
||||
includeMatches: true,
|
||||
minMatchCharLength: 1,
|
||||
})
|
||||
}, [searchableItems])
|
||||
|
||||
const modelSearchResults = useMemo(() => {
|
||||
const results: { id: string; html: string }[] = debouncedSearchTerm
|
||||
? highlight(fuse.search(debouncedSearchTerm), "model-item-highlight")
|
||||
: searchableItems
|
||||
return results
|
||||
}, [searchableItems, debouncedSearchTerm, fuse])
|
||||
|
||||
const parseHighlightedText = React.useCallback((htmlString: string) => {
|
||||
const parts = htmlString.split(/(<span class="model-item-highlight">.*?<\/span>)/g)
|
||||
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.startsWith('<span class="model-item-highlight">')) {
|
||||
const text = part.replace(/<span class="model-item-highlight">(.*?)<\/span>/, "$1")
|
||||
return (
|
||||
<span className="model-item-highlight" key={`highlight-${text}`}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return part || null
|
||||
})
|
||||
.filter((part) => part !== null && part !== "")
|
||||
}, [])
|
||||
|
||||
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)
|
||||
} else {
|
||||
// User typed a custom model ID
|
||||
handleModelChange(searchTerm)
|
||||
}
|
||||
setIsDropdownVisible(false)
|
||||
break
|
||||
case "Escape":
|
||||
setIsDropdownVisible(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hasInfo = useMemo(() => {
|
||||
return selectedModelInfo && selectedModelInfo.description
|
||||
}, [selectedModelInfo])
|
||||
|
||||
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="litellm-model-search">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
<DropdownWrapper ref={dropdownRef}>
|
||||
<VSCodeTextField
|
||||
id="litellm-model-search"
|
||||
onBlur={() => {
|
||||
if (searchTerm !== selectedModelId) {
|
||||
handleModelChange(searchTerm)
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsDropdownVisible(true)}
|
||||
onInput={(e) => {
|
||||
setSearchTerm((e.target as HTMLInputElement)?.value || "")
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search and select a model..."
|
||||
style={{
|
||||
width: "100%",
|
||||
zIndex: LITELLM_MODEL_PICKER_Z_INDEX,
|
||||
position: "relative",
|
||||
}}
|
||||
value={searchTerm}>
|
||||
{searchTerm && (
|
||||
<div
|
||||
aria-label="Clear search"
|
||||
className="input-icon-button codicon codicon-close"
|
||||
onClick={() => {
|
||||
setSearchTerm("")
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
slot="end"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
{isDropdownVisible && (
|
||||
<DropdownList ref={dropdownListRef}>
|
||||
{modelSearchResults.length > 0 ? (
|
||||
modelSearchResults.map((item, index) => (
|
||||
<DropdownItem
|
||||
isSelected={index === selectedIndex}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
handleModelChange(item.id)
|
||||
setIsDropdownVisible(false)
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
ref={(el) => (itemRefs.current[index] = el)}>
|
||||
{parseHighlightedText(item.html)}
|
||||
</DropdownItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownItem isSelected={false} style={{ cursor: "default", opacity: 0.7 }}>
|
||||
No models found. Type a model ID manually.
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownList>
|
||||
)}
|
||||
</DropdownWrapper>
|
||||
</div>
|
||||
|
||||
{hasInfo ? (
|
||||
<ModelInfoView isPopup={isPopup} modelInfo={selectedModelInfo} selectedModelId={selectedModelId} />
|
||||
) : (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 0,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
The extension automatically fetches the latest list of models from your configured LiteLLM server.{" "}
|
||||
<VSCodeLink href="https://docs.litellm.ai/docs/" style={{ display: "inline", fontSize: "inherit" }}>
|
||||
Learn more about LiteLLM.
|
||||
</VSCodeLink>{" "}
|
||||
If the model list fails to load, you can manually type a model ID (e.g.,{" "}
|
||||
<strong>anthropic/claude-sonnet-4-20250514</strong>).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const LITELLM_MODEL_PICKER_Z_INDEX = 1_000
|
||||
|
||||
export default LiteLlmModelPicker
|
||||
|
||||
// Dropdown
|
||||
|
||||
const DropdownWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
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: ${LITELLM_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);
|
||||
}
|
||||
`
|
||||
@@ -8,6 +8,7 @@ import { ModelsServiceClient } from "@/services/grpc-client"
|
||||
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "@/utils/vscStyles"
|
||||
import { DebouncedTextField } from "../common/DebouncedTextField"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import LiteLlmModelPicker from "../LiteLlmModelPicker"
|
||||
import ThinkingBudgetSlider from "../ThinkingBudgetSlider"
|
||||
import { getModeSpecificFields, normalizeApiConfiguration } from "../utils/providerUtils"
|
||||
|
||||
@@ -30,7 +31,7 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
|
||||
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, currentMode)
|
||||
|
||||
// Get mode-specific fields
|
||||
const { liteLlmModelId, liteLlmModelInfo } = getModeSpecificFields(apiConfiguration, currentMode)
|
||||
const { liteLlmModelInfo } = getModeSpecificFields(apiConfiguration, currentMode)
|
||||
|
||||
// Local state for collapsible model configuration section
|
||||
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
|
||||
@@ -69,20 +70,7 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
|
||||
type="password">
|
||||
<span style={{ fontWeight: 500 }}>API Key</span>
|
||||
</DebouncedTextField>
|
||||
<DebouncedTextField
|
||||
initialValue={liteLlmModelId || ""}
|
||||
onChange={async (value) => {
|
||||
await ModelsServiceClient.updateApiConfiguration(
|
||||
UpdateApiConfigurationRequestNew.create({
|
||||
options:
|
||||
currentMode === "plan" ? { planModeLiteLlmModelId: value } : { actModeLiteLlmModelId: value },
|
||||
}),
|
||||
)
|
||||
}}
|
||||
placeholder={"e.g. anthropic/claude-sonnet-4-20250514"}
|
||||
style={{ width: "100%" }}>
|
||||
<span style={{ fontWeight: 500 }}>Model ID</span>
|
||||
</DebouncedTextField>
|
||||
<LiteLlmModelPicker currentMode={currentMode} isPopup={isPopup} />
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", marginTop: 10, marginBottom: 10 }}>
|
||||
{selectedModelInfo.supportsPromptCache && (
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
basetenModels: Record<string, ModelInfo>
|
||||
huggingFaceModels: Record<string, ModelInfo>
|
||||
vercelAiGatewayModels: Record<string, ModelInfo>
|
||||
liteLlmModels: Record<string, ModelInfo>
|
||||
mcpServers: McpServer[]
|
||||
mcpMarketplaceCatalog: McpMarketplaceCatalog
|
||||
totalTasksSize: number | null
|
||||
@@ -71,6 +72,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
setBasetenModels: (value: Record<string, ModelInfo>) => void
|
||||
setHuggingFaceModels: (value: Record<string, ModelInfo>) => void
|
||||
setVercelAiGatewayModels: (value: Record<string, ModelInfo>) => void
|
||||
setLiteLlmModels: (value: Record<string, ModelInfo>) => void
|
||||
setGlobalClineRulesToggles: (toggles: Record<string, boolean>) => void
|
||||
setLocalClineRulesToggles: (toggles: Record<string, boolean>) => void
|
||||
setLocalCursorRulesToggles: (toggles: Record<string, boolean>) => void
|
||||
@@ -256,6 +258,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const [vercelAiGatewayModels, setVercelAiGatewayModels] = useState<Record<string, ModelInfo>>({
|
||||
[vercelAiGatewayDefaultModelId]: vercelAiGatewayDefaultModelInfo,
|
||||
})
|
||||
const [liteLlmModels, setLiteLlmModels] = useState<Record<string, ModelInfo>>({})
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState<McpMarketplaceCatalog>({ items: [] })
|
||||
|
||||
@@ -642,6 +645,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
basetenModels: basetenModelsState,
|
||||
huggingFaceModels,
|
||||
vercelAiGatewayModels,
|
||||
liteLlmModels,
|
||||
mcpServers,
|
||||
mcpMarketplaceCatalog,
|
||||
totalTasksSize,
|
||||
@@ -688,6 +692,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
setBasetenModels: (models: Record<string, ModelInfo>) => setBasetenModels(models),
|
||||
setHuggingFaceModels: (models: Record<string, ModelInfo>) => setHuggingFaceModels(models),
|
||||
setVercelAiGatewayModels: (models: Record<string, ModelInfo>) => setVercelAiGatewayModels(models),
|
||||
setLiteLlmModels: (models: Record<string, ModelInfo>) => setLiteLlmModels(models),
|
||||
setMcpMarketplaceCatalog: (catalog: McpMarketplaceCatalog) => setMcpMarketplaceCatalog(catalog),
|
||||
setShowMcp,
|
||||
closeMcpView,
|
||||
|
||||
Reference in New Issue
Block a user