mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3233f82ba | |||
| 77897ec651 | |||
| 3191b894a8 | |||
| 06fbc1c019 | |||
| 2dbfcbd02b | |||
| 2e945bd73e | |||
| 60ca42c24c | |||
| ae95bfb468 | |||
| 76f08b3d62 | |||
| d10f5408be | |||
| 7d80295be4 |
@@ -4,7 +4,8 @@ import SlashCommandMenu from "@/components/chat/SlashCommandMenu"
|
||||
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
|
||||
import Thumbnails from "@/components/common/Thumbnails"
|
||||
import Tooltip from "@/components/common/Tooltip"
|
||||
import ApiOptions, { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
|
||||
import ApiOptions from "@/components/settings/ApiOptions"
|
||||
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { FileServiceClient, StateServiceClient, ModelsServiceClient } from "@/services/grpc-client"
|
||||
import {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { TaskServiceClient, SlashServiceClient, FileServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import HistoryPreview from "@/components/history/HistoryPreview"
|
||||
import { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
|
||||
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
|
||||
import Announcement from "@/components/chat/Announcement"
|
||||
import BrowserSessionRow from "@/components/chat/BrowserSessionRow"
|
||||
import ChatRow from "@/components/chat/ChatRow"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
import Thumbnails from "@/components/common/Thumbnails"
|
||||
import { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
|
||||
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { FileServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import { formatLargeNumber, formatSize } from "@/utils/format"
|
||||
|
||||
@@ -1,54 +1,30 @@
|
||||
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { ModelsServiceClient } from "@/services/grpc-client"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "@/utils/vscStyles"
|
||||
import {
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
ApiConfiguration,
|
||||
ApiProvider,
|
||||
askSageDefaultModelId,
|
||||
askSageDefaultURL,
|
||||
askSageModels,
|
||||
azureOpenAiDefaultApiVersion,
|
||||
bedrockDefaultModelId,
|
||||
bedrockModels,
|
||||
cerebrasDefaultModelId,
|
||||
cerebrasModels,
|
||||
claudeCodeDefaultModelId,
|
||||
claudeCodeModels,
|
||||
deepSeekDefaultModelId,
|
||||
deepSeekModels,
|
||||
doubaoDefaultModelId,
|
||||
doubaoModels,
|
||||
geminiDefaultModelId,
|
||||
geminiModels,
|
||||
internationalQwenDefaultModelId,
|
||||
internationalQwenModels,
|
||||
liteLlmModelInfoSaneDefaults,
|
||||
mainlandQwenDefaultModelId,
|
||||
mainlandQwenModels,
|
||||
mistralDefaultModelId,
|
||||
mistralModels,
|
||||
ModelInfo,
|
||||
nebiusDefaultModelId,
|
||||
nebiusModels,
|
||||
openAiModelInfoSaneDefaults,
|
||||
openAiNativeDefaultModelId,
|
||||
openAiNativeModels,
|
||||
openRouterDefaultModelId,
|
||||
openRouterDefaultModelInfo,
|
||||
requestyDefaultModelId,
|
||||
requestyDefaultModelInfo,
|
||||
sambanovaDefaultModelId,
|
||||
sambanovaModels,
|
||||
vertexDefaultModelId,
|
||||
vertexGlobalModels,
|
||||
vertexModels,
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
sapAiCoreDefaultModelId,
|
||||
sapAiCoreModels,
|
||||
} from "@shared/api"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/common"
|
||||
@@ -68,12 +44,16 @@ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } fro
|
||||
import { useInterval } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import * as vscodemodels from "vscode"
|
||||
import { useOpenRouterKeyInfo } from "../ui/hooks/useOpenRouterKeyInfo"
|
||||
import { ClineAccountInfoCard } from "./ClineAccountInfoCard"
|
||||
import OllamaModelPicker from "./OllamaModelPicker"
|
||||
import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
|
||||
import RequestyModelPicker from "./RequestyModelPicker"
|
||||
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
|
||||
import { formatPrice } from "./utils/pricingUtils"
|
||||
import { normalizeApiConfiguration } from "./utils/providerUtils"
|
||||
|
||||
import { OpenRouterProvider } from "./providers/OpenRouterProvider"
|
||||
import { MistralProvider } from "./providers/MistralProvider"
|
||||
import { ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
@@ -84,44 +64,6 @@ interface ApiOptionsProps {
|
||||
saveImmediately?: boolean // Add prop to control immediate saving
|
||||
}
|
||||
|
||||
const OpenRouterBalanceDisplay = ({ apiKey }: { apiKey: string }) => {
|
||||
const { data: keyInfo, isLoading, error } = useOpenRouterKeyInfo(apiKey)
|
||||
|
||||
if (isLoading) {
|
||||
return <span style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)" }}>Loading...</span>
|
||||
}
|
||||
|
||||
if (error || !keyInfo || keyInfo.limit === null) {
|
||||
// Don't show anything if there's an error, no info, or no limit set
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate remaining balance
|
||||
const remainingBalance = keyInfo.limit - keyInfo.usage
|
||||
const formattedBalance = remainingBalance.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
|
||||
return (
|
||||
<VSCodeLink
|
||||
href="https://openrouter.ai/settings/keys"
|
||||
title={`Remaining balance: ${formattedBalance}\nLimit: ${keyInfo.limit.toLocaleString("en-US", { style: "currency", currency: "USD" })}\nUsage: ${keyInfo.usage.toLocaleString("en-US", { style: "currency", currency: "USD" })}`}
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-foreground)",
|
||||
textDecoration: "none",
|
||||
fontWeight: 500,
|
||||
paddingLeft: 4,
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
Balance: {formattedBalance}
|
||||
</VSCodeLink>
|
||||
)
|
||||
}
|
||||
|
||||
const SUPPORTED_THINKING_MODELS: Record<string, string[]> = {
|
||||
anthropic: ["claude-3-7-sonnet-20250219", "claude-sonnet-4-20250514", "claude-opus-4-20250514"],
|
||||
vertex: [
|
||||
@@ -646,75 +588,23 @@ const ApiOptions = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "mistral" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.mistralApiKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("mistralApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Mistral API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 3,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
{!apiConfiguration?.mistralApiKey && (
|
||||
<VSCodeLink
|
||||
href="https://console.mistral.ai/codestral"
|
||||
style={{
|
||||
display: "inline",
|
||||
fontSize: "inherit",
|
||||
}}>
|
||||
You can get a Mistral API key by signing up here.
|
||||
</VSCodeLink>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{apiConfiguration && selectedProvider === "mistral" && (
|
||||
<MistralProvider
|
||||
apiConfiguration={apiConfiguration}
|
||||
handleInputChange={handleInputChange}
|
||||
showModelOptions={showModelOptions}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider === "openrouter" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openRouterApiKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("openRouterApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%" }}>
|
||||
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
|
||||
{apiConfiguration?.openRouterApiKey && (
|
||||
<OpenRouterBalanceDisplay apiKey={apiConfiguration.openRouterApiKey} />
|
||||
)}
|
||||
</div>
|
||||
</VSCodeTextField>
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<VSCodeButtonLink
|
||||
href={getOpenRouterAuthUrl(uriScheme)}
|
||||
style={{ margin: "5px 0 0 0" }}
|
||||
appearance="secondary">
|
||||
Get OpenRouter API Key
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.{" "}
|
||||
{/* {!apiConfiguration?.openRouterApiKey && (
|
||||
<span style={{ color: "var(--vscode-charts-green)" }}>
|
||||
(<span style={{ fontWeight: 500 }}>Note:</span> OpenRouter is recommended for high rate
|
||||
limits, prompt caching, and wider selection of models.)
|
||||
</span>
|
||||
)} */}
|
||||
</p>
|
||||
</div>
|
||||
{apiConfiguration && selectedProvider === "openrouter" && (
|
||||
<OpenRouterProvider
|
||||
apiConfiguration={apiConfiguration}
|
||||
handleInputChange={handleInputChange}
|
||||
showModelOptions={showModelOptions}
|
||||
isPopup={isPopup}
|
||||
uriScheme={uriScheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider === "bedrock" && (
|
||||
@@ -2210,7 +2100,7 @@ const ApiOptions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedProvider === "openrouter" || selectedProvider === "cline") && showModelOptions && (
|
||||
{selectedProvider === "cline" && showModelOptions && (
|
||||
<>
|
||||
<VSCodeCheckbox
|
||||
style={{ marginTop: -10 }}
|
||||
@@ -2270,6 +2160,7 @@ const ApiOptions = ({
|
||||
selectedProvider !== "litellm" &&
|
||||
selectedProvider !== "requesty" &&
|
||||
selectedProvider !== "bedrock" &&
|
||||
selectedProvider !== "mistral" &&
|
||||
showModelOptions && (
|
||||
<>
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
|
||||
@@ -2288,7 +2179,6 @@ const ApiOptions = ({
|
||||
apiConfiguration?.qwenApiLine === "china" ? mainlandQwenModels : internationalQwenModels,
|
||||
)}
|
||||
{selectedProvider === "doubao" && createDropdown(doubaoModels)}
|
||||
{selectedProvider === "mistral" && createDropdown(mistralModels)}
|
||||
{selectedProvider === "asksage" && createDropdown(askSageModels)}
|
||||
{selectedProvider === "xai" && createDropdown(xaiModels)}
|
||||
{selectedProvider === "sambanova" && createDropdown(sambanovaModels)}
|
||||
@@ -2366,9 +2256,7 @@ const ApiOptions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedProvider === "openrouter" || selectedProvider === "cline") && showModelOptions && (
|
||||
<OpenRouterModelPicker isPopup={isPopup} />
|
||||
)}
|
||||
{selectedProvider === "cline" && showModelOptions && <OpenRouterModelPicker isPopup={isPopup} />}
|
||||
{selectedProvider === "requesty" && showModelOptions && <RequestyModelPicker isPopup={isPopup} />}
|
||||
|
||||
{modelIdErrorMessage && (
|
||||
@@ -2385,19 +2273,6 @@ const ApiOptions = ({
|
||||
)
|
||||
}
|
||||
|
||||
export function getOpenRouterAuthUrl(uriScheme?: string) {
|
||||
return `https://openrouter.ai/auth?callback_url=${uriScheme || "vscode"}://saoudrizwan.claude-dev/openrouter`
|
||||
}
|
||||
|
||||
export const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
// Returns an array of formatted tier strings
|
||||
const formatTiers = (
|
||||
tiers: ModelInfo["tiers"],
|
||||
@@ -2602,137 +2477,4 @@ const ModelInfoSupportsItem = ({
|
||||
</span>
|
||||
)
|
||||
|
||||
export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration): {
|
||||
selectedProvider: ApiProvider
|
||||
selectedModelId: string
|
||||
selectedModelInfo: ModelInfo
|
||||
} {
|
||||
const provider = apiConfiguration?.apiProvider || "anthropic"
|
||||
const modelId = apiConfiguration?.apiModelId
|
||||
|
||||
const getProviderData = (models: Record<string, ModelInfo>, defaultId: string) => {
|
||||
let selectedModelId: string
|
||||
let selectedModelInfo: ModelInfo
|
||||
if (modelId && modelId in models) {
|
||||
selectedModelId = modelId
|
||||
selectedModelInfo = models[modelId]
|
||||
} else {
|
||||
selectedModelId = defaultId
|
||||
selectedModelInfo = models[defaultId]
|
||||
}
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId,
|
||||
selectedModelInfo,
|
||||
}
|
||||
}
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
case "claude-code":
|
||||
return getProviderData(claudeCodeModels, claudeCodeDefaultModelId)
|
||||
case "bedrock":
|
||||
if (apiConfiguration?.awsBedrockCustomSelected) {
|
||||
const baseModelId = apiConfiguration.awsBedrockCustomModelBaseId
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: modelId || bedrockDefaultModelId,
|
||||
selectedModelInfo: (baseModelId && bedrockModels[baseModelId]) || bedrockModels[bedrockDefaultModelId],
|
||||
}
|
||||
}
|
||||
return getProviderData(bedrockModels, bedrockDefaultModelId)
|
||||
case "vertex":
|
||||
return getProviderData(vertexModels, vertexDefaultModelId)
|
||||
case "gemini":
|
||||
return getProviderData(geminiModels, geminiDefaultModelId)
|
||||
case "openai-native":
|
||||
return getProviderData(openAiNativeModels, openAiNativeDefaultModelId)
|
||||
case "deepseek":
|
||||
return getProviderData(deepSeekModels, deepSeekDefaultModelId)
|
||||
case "qwen":
|
||||
const qwenModels = apiConfiguration?.qwenApiLine === "china" ? mainlandQwenModels : internationalQwenModels
|
||||
const qwenDefaultId =
|
||||
apiConfiguration?.qwenApiLine === "china" ? mainlandQwenDefaultModelId : internationalQwenDefaultModelId
|
||||
return getProviderData(qwenModels, qwenDefaultId)
|
||||
case "doubao":
|
||||
return getProviderData(doubaoModels, doubaoDefaultModelId)
|
||||
case "mistral":
|
||||
return getProviderData(mistralModels, mistralDefaultModelId)
|
||||
case "asksage":
|
||||
return getProviderData(askSageModels, askSageDefaultModelId)
|
||||
case "openrouter":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo,
|
||||
}
|
||||
case "requesty":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
|
||||
}
|
||||
case "cline":
|
||||
const openRouterModelId = apiConfiguration?.openRouterModelId || openRouterDefaultModelId
|
||||
const openRouterModelInfo = apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: openRouterModelId,
|
||||
// TODO: remove this once we have a better way to handle free models on Cline
|
||||
// Free grok 3 promotion
|
||||
selectedModelInfo:
|
||||
openRouterModelId === "x-ai/grok-3"
|
||||
? { ...openRouterModelInfo, inputPrice: 0, outputPrice: 0 }
|
||||
: openRouterModelInfo,
|
||||
}
|
||||
case "openai":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.openAiModelId || "",
|
||||
selectedModelInfo: apiConfiguration?.openAiModelInfo || openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "ollama":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.ollamaModelId || "",
|
||||
selectedModelInfo: openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "lmstudio":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.lmStudioModelId || "",
|
||||
selectedModelInfo: openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "vscode-lm":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.vsCodeLmModelSelector
|
||||
? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}`
|
||||
: "",
|
||||
selectedModelInfo: {
|
||||
...openAiModelInfoSaneDefaults,
|
||||
supportsImages: false, // VSCode LM API currently doesn't support images
|
||||
},
|
||||
}
|
||||
case "litellm":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.liteLlmModelId || "",
|
||||
selectedModelInfo: apiConfiguration?.liteLlmModelInfo || liteLlmModelInfoSaneDefaults,
|
||||
}
|
||||
case "xai":
|
||||
return getProviderData(xaiModels, xaiDefaultModelId)
|
||||
case "nebius":
|
||||
return getProviderData(nebiusModels, nebiusDefaultModelId)
|
||||
case "sambanova":
|
||||
return getProviderData(sambanovaModels, sambanovaDefaultModelId)
|
||||
case "cerebras":
|
||||
return getProviderData(cerebrasModels, cerebrasDefaultModelId)
|
||||
case "sapaicore":
|
||||
return getProviderData(sapAiCoreModels, sapAiCoreDefaultModelId)
|
||||
default:
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
}
|
||||
}
|
||||
|
||||
export default memo(ApiOptions)
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useRemark } from "react-remark"
|
||||
import { useMount } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import { highlight } from "../history/HistoryView"
|
||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||
import { ModelInfoView } from "./ApiOptions"
|
||||
import { normalizeApiConfiguration } from "./utils/providerUtils"
|
||||
import FeaturedModelCard from "./FeaturedModelCard"
|
||||
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# API Options Component Architecture
|
||||
|
||||
This directory contains the refactored API Options components for the Cline extension. The refactoring aims to improve maintainability, code organization, and reduce complexity by separating provider-specific code into modular components.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
settings/
|
||||
├── ApiOptions.tsx # Main component that renders provider-specific components
|
||||
├── common/ # Reusable UI components
|
||||
│ ├── ApiKeyField.tsx # API key input with standard styling
|
||||
│ ├── BaseUrlField.tsx # Base URL input with standard styling
|
||||
│ ├── ErrorMessage.tsx # Standard error message display
|
||||
│ ├── ModelInfoView.tsx # Model information display
|
||||
│ └── ModelSelector.tsx # Model selection dropdown
|
||||
├── providers/ # Provider-specific components
|
||||
│ ├── ClineProvider.tsx # Cline configuration
|
||||
│ ├── AnthropicProvider.tsx # Anthropic-specific configuration
|
||||
│ ├── BedrockProvider.tsx # AWS Bedrock configuration
|
||||
│ ├── GeminiProvider.tsx # Google Gemini configuration
|
||||
│ ├── MistralProvider.tsx # Mistral configuration
|
||||
│ ├── OllamaProvider.tsx # Ollama configuration
|
||||
│ ├── OpenAICompatibleProvider.tsx # OpenAI compatible API configuration
|
||||
│ ├── OpenRouterProvider.tsx # OpenRouter configuration
|
||||
│ └── ...
|
||||
└── utils/ # Utility functions
|
||||
├── pricingUtils.ts # Pricing formatting utilities
|
||||
└── providerUtils.ts # API configuration normalization
|
||||
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
```
|
||||
ApiOptions
|
||||
└── [ProviderComponent] (based on selected provider)
|
||||
├── ApiKeyField (if needed)
|
||||
├── BaseUrlField (if needed)
|
||||
├── ModelSelector (if showing model options)
|
||||
└── ModelInfoView (if showing model options)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. `ApiOptions` receives the current API configuration from the extension state
|
||||
2. When a provider is selected, it renders the corresponding provider component
|
||||
3. Provider-specific components receive `apiConfiguration` and `handleInputChange` to manage their state
|
||||
4. Changes are propagated back to the extension via the `handleInputChange` callback
|
||||
|
||||
## Adding a New Provider
|
||||
|
||||
To add a new provider:
|
||||
|
||||
1. Create a new file in the `providers` directory, e.g. `MyNewProvider.tsx`
|
||||
2. Implement the provider component using this template:
|
||||
|
||||
```tsx
|
||||
import { ApiConfiguration, myNewProviderModels } from "@shared/api"
|
||||
import { ApiKeyField } from "../common/ApiKeyField"
|
||||
import { BaseUrlField } from "../common/BaseUrlField"
|
||||
import { ModelSelector } from "../common/ModelSelector"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { normalizeApiConfiguration } from "../utils/providerUtils"
|
||||
|
||||
/**
|
||||
* Props for the MyNewProvider component
|
||||
*/
|
||||
interface MyNewProviderProps {
|
||||
apiConfiguration: ApiConfiguration
|
||||
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The MyNewProvider configuration component
|
||||
*/
|
||||
export const MyNewProvider = ({
|
||||
apiConfiguration,
|
||||
handleInputChange,
|
||||
showModelOptions,
|
||||
isPopup,
|
||||
}: MyNewProviderProps) => {
|
||||
// Get the normalized configuration
|
||||
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Add provider-specific fields */}
|
||||
<ApiKeyField
|
||||
value={apiConfiguration?.myNewProviderApiKey || ""}
|
||||
onChange={handleInputChange("myNewProviderApiKey")}
|
||||
providerName="My New Provider"
|
||||
signupUrl="https://mynewprovider.com/signup"
|
||||
/>
|
||||
|
||||
{/* Optional: Base URL field if the provider supports custom endpoints */}
|
||||
<BaseUrlField
|
||||
value={apiConfiguration?.myNewProviderBaseUrl}
|
||||
onChange={handleInputChange("myNewProviderBaseUrl")}
|
||||
defaultPlaceholder="https://api.mynewprovider.com"
|
||||
/>
|
||||
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<ModelSelector
|
||||
models={myNewProviderModels}
|
||||
selectedModelId={selectedModelId}
|
||||
onChange={handleInputChange("apiModelId")}
|
||||
label="Model"
|
||||
/>
|
||||
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
3. Import and add the new provider component to `ApiOptions.tsx`:
|
||||
|
||||
```tsx
|
||||
import { MyNewProvider } from "./providers/MyNewProvider"
|
||||
|
||||
// ...
|
||||
|
||||
{apiConfiguration && selectedProvider === "mynewprovider" && (
|
||||
<MyNewProvider
|
||||
apiConfiguration={apiConfiguration}
|
||||
handleInputChange={handleInputChange}
|
||||
showModelOptions={showModelOptions}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
4. Add the provider to the dropdown options:
|
||||
|
||||
```tsx
|
||||
<VSCodeOption value="mynewprovider">My New Provider</VSCodeOption>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Reuse Common Components**: Use the common components for consistent UI and behavior
|
||||
2. **Provider-Specific Logic**: Keep provider-specific logic within the provider component
|
||||
3. **Type Safety**: Ensure all props and state are properly typed
|
||||
4. **Error Handling**: Handle edge cases gracefully, such as missing configurations
|
||||
5. **Documentation**: Document any provider-specific behaviors or requirements
|
||||
|
||||
## Testing
|
||||
|
||||
Each provider component should be tested in isolation to ensure it renders correctly and handles user input properly.
|
||||
@@ -10,7 +10,8 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { ModelsServiceClient } from "../../services/grpc-client"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import { highlight } from "../history/HistoryView"
|
||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||
import { ModelInfoView } from "./ApiOptions"
|
||||
import { normalizeApiConfiguration } from "./utils/providerUtils"
|
||||
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
|
||||
|
||||
export interface RequestyModelPickerProps {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { VSCodeTextField, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
/**
|
||||
* Props for the ApiKeyField component
|
||||
*/
|
||||
interface ApiKeyFieldProps {
|
||||
value: string
|
||||
onChange: (e: any) => void
|
||||
providerName: string
|
||||
signupUrl?: string
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable component for API key input fields with standard styling and help text for signing up for key
|
||||
*/
|
||||
export const ApiKeyField = ({
|
||||
value,
|
||||
onChange,
|
||||
providerName,
|
||||
signupUrl,
|
||||
placeholder = "Enter API Key...",
|
||||
helpText,
|
||||
}: ApiKeyFieldProps) => (
|
||||
<div>
|
||||
<VSCodeTextField value={value} style={{ width: "100%" }} type="password" onInput={onChange} placeholder={placeholder}>
|
||||
<span style={{ fontWeight: 500 }}>{providerName} API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 3,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
{helpText || "This key is stored locally and only used to make API requests from this extension."}
|
||||
{!value && signupUrl && (
|
||||
<VSCodeLink
|
||||
href={signupUrl}
|
||||
style={{
|
||||
display: "inline",
|
||||
fontSize: "inherit",
|
||||
}}>
|
||||
You can get a{/^[aeiou]/i.test(providerName) ? "n" : ""} {providerName} API key by signing up here.
|
||||
</VSCodeLink>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
/**
|
||||
* Props for the BaseUrlField component
|
||||
*/
|
||||
interface BaseUrlFieldProps {
|
||||
value: string | undefined
|
||||
onChange: (value: string) => void
|
||||
defaultValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable component for toggling and entering custom base URLs
|
||||
*/
|
||||
export const BaseUrlField = ({
|
||||
value,
|
||||
onChange,
|
||||
defaultValue = "",
|
||||
label = "Use custom base URL",
|
||||
placeholder = "Default: https://api.example.com",
|
||||
}: BaseUrlFieldProps) => {
|
||||
const [isEnabled, setIsEnabled] = useState(!!value)
|
||||
|
||||
// When value changes externally, update isEnabled state
|
||||
useEffect(() => {
|
||||
setIsEnabled(!!value)
|
||||
}, [value])
|
||||
|
||||
const handleToggle = (e: any) => {
|
||||
const checked = e.target.checked === true
|
||||
setIsEnabled(checked)
|
||||
if (!checked) {
|
||||
onChange("")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<VSCodeCheckbox checked={isEnabled} onChange={handleToggle}>
|
||||
{label}
|
||||
</VSCodeCheckbox>
|
||||
|
||||
{isEnabled && (
|
||||
<VSCodeTextField
|
||||
value={value || ""}
|
||||
style={{ width: "100%", marginTop: 3 }}
|
||||
type="url"
|
||||
onInput={(e: any) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
/**
|
||||
* Props for the ErrorMessage component
|
||||
*/
|
||||
interface ErrorMessageProps {
|
||||
message: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable component for displaying error messages
|
||||
*/
|
||||
export const ErrorMessage = ({ message, style }: ErrorMessageProps) => {
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
margin: "-10px 0 4px 0",
|
||||
fontSize: 12,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
...style,
|
||||
}}>
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { Fragment, useState } from "react"
|
||||
import { ModelInfo, geminiModels } from "@shared/api"
|
||||
import { ModelDescriptionMarkdown } from "../OpenRouterModelPicker"
|
||||
import {
|
||||
formatPrice,
|
||||
hasThinkingBudget,
|
||||
supportsImages,
|
||||
supportsBrowserUse,
|
||||
supportsPromptCache,
|
||||
formatTokenPrice,
|
||||
formatTokenLimit,
|
||||
} from "../utils/pricingUtils"
|
||||
|
||||
/**
|
||||
* Returns an array of formatted tier strings
|
||||
*/
|
||||
const formatTiers = (
|
||||
tiers: ModelInfo["tiers"],
|
||||
priceType: "inputPrice" | "outputPrice" | "cacheReadsPrice" | "cacheWritesPrice",
|
||||
): JSX.Element[] => {
|
||||
if (!tiers || tiers.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return tiers
|
||||
.map((tier, index, arr) => {
|
||||
const prevLimit = index > 0 ? arr[index - 1].contextWindow : 0
|
||||
const price = tier[priceType]
|
||||
|
||||
if (price === undefined) return null
|
||||
|
||||
return (
|
||||
<span style={{ paddingLeft: "15px" }} key={index}>
|
||||
{formatPrice(price)}/million tokens (
|
||||
{tier.contextWindow === Number.POSITIVE_INFINITY ? (
|
||||
<span>
|
||||
{">"} {prevLimit.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{"<="} {tier.contextWindow.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{" tokens)"}
|
||||
{index < arr.length - 1 && <br />}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
.filter((element): element is JSX.Element => element !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the ModelInfoSupportsItem component
|
||||
*/
|
||||
interface ModelInfoSupportsItemProps {
|
||||
isSupported: boolean
|
||||
supportsLabel: string
|
||||
doesNotSupportLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A component to show a feature support indicator with an icon
|
||||
*/
|
||||
const ModelInfoSupportsItem = ({ isSupported, supportsLabel, doesNotSupportLabel }: ModelInfoSupportsItemProps) => (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: isSupported ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
<i
|
||||
className={`codicon codicon-${isSupported ? "check" : "x"}`}
|
||||
style={{
|
||||
marginRight: 4,
|
||||
marginBottom: isSupported ? 1 : -1,
|
||||
fontSize: isSupported ? 11 : 13,
|
||||
fontWeight: 700,
|
||||
display: "inline-block",
|
||||
verticalAlign: "bottom",
|
||||
}}></i>
|
||||
{isSupported ? supportsLabel : doesNotSupportLabel}
|
||||
</span>
|
||||
)
|
||||
|
||||
/**
|
||||
* Props for the ModelInfoView component
|
||||
*/
|
||||
interface ModelInfoViewProps {
|
||||
selectedModelId: string
|
||||
modelInfo: ModelInfo
|
||||
isPopup?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable component for displaying model information
|
||||
* This component manages its own description expansion state
|
||||
*/
|
||||
export const ModelInfoView = ({ selectedModelId, modelInfo, isPopup }: ModelInfoViewProps) => {
|
||||
// Internal state management for description expansion
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
|
||||
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
|
||||
const hasThinkingConfig = hasThinkingBudget(modelInfo)
|
||||
const hasTiers = !!modelInfo.tiers && modelInfo.tiers.length > 0
|
||||
|
||||
// Create elements for input pricing
|
||||
const inputPriceElement = hasTiers ? (
|
||||
<Fragment key="inputPriceTiers">
|
||||
<span style={{ fontWeight: 500 }}>Input price:</span>
|
||||
<br />
|
||||
{formatTiers(modelInfo.tiers, "inputPrice")}
|
||||
</Fragment>
|
||||
) : modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 ? (
|
||||
<span key="inputPrice">
|
||||
<span style={{ fontWeight: 500 }}>Input price:</span> {formatTokenPrice(modelInfo.inputPrice)}
|
||||
</span>
|
||||
) : null
|
||||
|
||||
// --- Output Price Logic ---
|
||||
let outputPriceElement = null
|
||||
if (hasThinkingConfig && modelInfo.outputPrice !== undefined && modelInfo.thinkingConfig?.outputPrice !== undefined) {
|
||||
// Display both standard and thinking budget prices
|
||||
outputPriceElement = (
|
||||
<Fragment key="outputPriceConditional">
|
||||
<span style={{ fontWeight: 500 }}>Output price (Standard):</span> {formatTokenPrice(modelInfo.outputPrice)}
|
||||
<br />
|
||||
<span style={{ fontWeight: 500 }}>Output price (Thinking Budget > 0):</span>{" "}
|
||||
{formatTokenPrice(modelInfo.thinkingConfig.outputPrice)}
|
||||
</Fragment>
|
||||
)
|
||||
} else if (hasTiers) {
|
||||
// Display tiered output pricing
|
||||
outputPriceElement = (
|
||||
<Fragment key="outputPriceTiers">
|
||||
<span style={{ fontWeight: 500 }}>Output price:</span>
|
||||
<span style={{ fontStyle: "italic" }}> (based on input tokens)</span>
|
||||
<br />
|
||||
{formatTiers(modelInfo.tiers, "outputPrice")}
|
||||
</Fragment>
|
||||
)
|
||||
} else if (modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0) {
|
||||
// Display single standard output price
|
||||
outputPriceElement = (
|
||||
<span key="outputPrice">
|
||||
<span style={{ fontWeight: 500 }}>Output price:</span> {formatTokenPrice(modelInfo.outputPrice)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// --- End Output Price Logic ---
|
||||
|
||||
const infoItems = [
|
||||
modelInfo.description && (
|
||||
<ModelDescriptionMarkdown
|
||||
key="description"
|
||||
markdown={modelInfo.description}
|
||||
isExpanded={isDescriptionExpanded}
|
||||
setIsExpanded={setIsDescriptionExpanded}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
),
|
||||
<ModelInfoSupportsItem
|
||||
key="supportsImages"
|
||||
isSupported={supportsImages(modelInfo)}
|
||||
supportsLabel="Supports images"
|
||||
doesNotSupportLabel="Does not support images"
|
||||
/>,
|
||||
<ModelInfoSupportsItem
|
||||
key="supportsBrowserUse"
|
||||
isSupported={supportsBrowserUse(modelInfo)}
|
||||
supportsLabel="Supports browser use"
|
||||
doesNotSupportLabel="Does not support browser use"
|
||||
/>,
|
||||
!isGemini && (
|
||||
<ModelInfoSupportsItem
|
||||
key="supportsPromptCache"
|
||||
isSupported={supportsPromptCache(modelInfo)}
|
||||
supportsLabel="Supports prompt caching"
|
||||
doesNotSupportLabel="Does not support prompt caching"
|
||||
/>
|
||||
),
|
||||
modelInfo.maxTokens !== undefined && modelInfo.maxTokens > 0 && (
|
||||
<span key="maxTokens">
|
||||
<span style={{ fontWeight: 500 }}>Max output:</span> {formatTokenLimit(modelInfo.maxTokens)} tokens
|
||||
</span>
|
||||
),
|
||||
inputPriceElement, // Add the generated input price block
|
||||
modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && (
|
||||
<span key="cacheWritesPrice">
|
||||
<span style={{ fontWeight: 500 }}>Cache writes price:</span> {formatTokenPrice(modelInfo.cacheWritesPrice || 0)}
|
||||
</span>
|
||||
),
|
||||
modelInfo.supportsPromptCache && modelInfo.cacheReadsPrice && (
|
||||
<span key="cacheReadsPrice">
|
||||
<span style={{ fontWeight: 500 }}>Cache reads price:</span> {formatTokenPrice(modelInfo.cacheReadsPrice || 0)}
|
||||
</span>
|
||||
),
|
||||
outputPriceElement, // Add the generated output price block
|
||||
isGemini && (
|
||||
<span key="geminiInfo" style={{ fontStyle: "italic" }}>
|
||||
* Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute. After that,
|
||||
billing depends on prompt size.{" "}
|
||||
<VSCodeLink href="https://ai.google.dev/pricing" style={{ display: "inline", fontSize: "inherit" }}>
|
||||
For more info, see pricing details.
|
||||
</VSCodeLink>
|
||||
</span>
|
||||
),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "2px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
{infoItems.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
{item}
|
||||
{index < infoItems.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import styled from "styled-components"
|
||||
|
||||
/**
|
||||
* Container for dropdowns that ensures proper z-index handling
|
||||
* This is necessary to ensure dropdown opens downward
|
||||
*/
|
||||
export const DropdownContainer = styled.div.attrs<{ zIndex?: number }>(({ zIndex }) => ({
|
||||
style: {
|
||||
zIndex: zIndex || 1000,
|
||||
},
|
||||
}))`
|
||||
position: relative;
|
||||
|
||||
// Force dropdowns to open downward
|
||||
& vscode-dropdown::part(listbox) {
|
||||
position: absolute !important;
|
||||
top: 100% !important;
|
||||
bottom: auto !important;
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Props for the ModelSelector component
|
||||
*/
|
||||
interface ModelSelectorProps {
|
||||
models: Record<string, ModelInfo>
|
||||
selectedModelId: string | undefined
|
||||
onChange: (e: any) => void
|
||||
zIndex?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable component for selecting models from a dropdown
|
||||
*/
|
||||
export const ModelSelector = ({ models, selectedModelId, onChange, zIndex, label = "Model" }: ModelSelectorProps) => {
|
||||
return (
|
||||
<DropdownContainer className="dropdown-container" zIndex={zIndex}>
|
||||
<label htmlFor="model-id">
|
||||
<span style={{ fontWeight: 500 }}>{label}</span>
|
||||
</label>
|
||||
<VSCodeDropdown id="model-id" value={selectedModelId} onChange={onChange} style={{ width: "100%" }}>
|
||||
<VSCodeOption value="">Select a model...</VSCodeOption>
|
||||
{Object.keys(models).map((modelId) => (
|
||||
<VSCodeOption
|
||||
key={modelId}
|
||||
value={modelId}
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordWrap: "break-word",
|
||||
maxWidth: "100%",
|
||||
}}>
|
||||
{modelId}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
</DropdownContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiConfiguration, mistralModels } from "@shared/api"
|
||||
import { ApiKeyField } from "../common/ApiKeyField"
|
||||
import { ModelSelector } from "../common/ModelSelector"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { normalizeApiConfiguration } from "../utils/providerUtils"
|
||||
|
||||
/**
|
||||
* Props for the MistralProvider component
|
||||
*/
|
||||
interface MistralProviderProps {
|
||||
apiConfiguration: ApiConfiguration
|
||||
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The Mistral provider configuration component
|
||||
*/
|
||||
export const MistralProvider = ({ apiConfiguration, handleInputChange, showModelOptions, isPopup }: MistralProviderProps) => {
|
||||
// Get the normalized configuration
|
||||
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ApiKeyField
|
||||
value={apiConfiguration?.mistralApiKey || ""}
|
||||
onChange={handleInputChange("mistralApiKey")}
|
||||
providerName="Mistral"
|
||||
signupUrl="https://console.mistral.ai/codestral"
|
||||
/>
|
||||
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<ModelSelector
|
||||
models={mistralModels}
|
||||
selectedModelId={selectedModelId}
|
||||
onChange={handleInputChange("apiModelId")}
|
||||
label="Model"
|
||||
/>
|
||||
|
||||
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ApiConfiguration } from "@shared/api"
|
||||
import { VSCodeCheckbox, VSCodeDropdown, VSCodeOption, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { DropdownContainer } from "../common/ModelSelector"
|
||||
import { useState } from "react"
|
||||
import { getOpenRouterAuthUrl } from "../utils/providerUtils"
|
||||
import { useOpenRouterKeyInfo } from "../../ui/hooks/useOpenRouterKeyInfo"
|
||||
import VSCodeButtonLink from "../../common/VSCodeButtonLink"
|
||||
import OpenRouterModelPicker, { OPENROUTER_MODEL_PICKER_Z_INDEX } from "../OpenRouterModelPicker"
|
||||
import { formatPrice } from "../utils/pricingUtils"
|
||||
|
||||
/**
|
||||
* Component to display OpenRouter balance information
|
||||
*/
|
||||
const OpenRouterBalanceDisplay = ({ apiKey }: { apiKey: string }) => {
|
||||
const { data: keyInfo, isLoading, error } = useOpenRouterKeyInfo(apiKey)
|
||||
|
||||
if (isLoading) {
|
||||
return <span style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)" }}>Loading...</span>
|
||||
}
|
||||
|
||||
if (error || !keyInfo || keyInfo.limit === null) {
|
||||
// Don't show anything if there's an error, no info, or no limit set
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate remaining balance
|
||||
const remainingBalance = keyInfo.limit - keyInfo.usage
|
||||
const formattedBalance = formatPrice(remainingBalance)
|
||||
|
||||
return (
|
||||
<VSCodeLink
|
||||
href="https://openrouter.ai/settings/keys"
|
||||
title={`Remaining balance: ${formattedBalance}\nLimit: ${formatPrice(keyInfo.limit)}\nUsage: ${formatPrice(keyInfo.usage)}`}
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-foreground)",
|
||||
textDecoration: "none",
|
||||
fontWeight: 500,
|
||||
paddingLeft: 4,
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
Balance: {formattedBalance}
|
||||
</VSCodeLink>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the OpenRouterProvider component
|
||||
*/
|
||||
interface OpenRouterProviderProps {
|
||||
apiConfiguration: ApiConfiguration
|
||||
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
uriScheme?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenRouter provider configuration component
|
||||
*/
|
||||
export const OpenRouterProvider = ({
|
||||
apiConfiguration,
|
||||
handleInputChange,
|
||||
showModelOptions,
|
||||
isPopup,
|
||||
uriScheme,
|
||||
}: OpenRouterProviderProps) => {
|
||||
const [providerSortingSelected, setProviderSortingSelected] = useState(!!apiConfiguration?.openRouterProviderSorting)
|
||||
|
||||
// Create a wrapper for handling field changes more directly
|
||||
const handleFieldChange = (field: keyof ApiConfiguration) => (value: any) => {
|
||||
handleInputChange(field)({ target: { value } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openRouterApiKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("openRouterApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%" }}>
|
||||
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
|
||||
{apiConfiguration?.openRouterApiKey && (
|
||||
<OpenRouterBalanceDisplay apiKey={apiConfiguration.openRouterApiKey} />
|
||||
)}
|
||||
</div>
|
||||
</VSCodeTextField>
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<VSCodeButtonLink
|
||||
href={getOpenRouterAuthUrl(uriScheme)}
|
||||
style={{ margin: "5px 0 0 0" }}
|
||||
appearance="secondary">
|
||||
Get OpenRouter API Key
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<VSCodeCheckbox
|
||||
style={{ marginTop: -10 }}
|
||||
checked={providerSortingSelected}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
setProviderSortingSelected(isChecked)
|
||||
if (!isChecked) {
|
||||
handleFieldChange("openRouterProviderSorting")("")
|
||||
}
|
||||
}}>
|
||||
Sort underlying provider routing
|
||||
</VSCodeCheckbox>
|
||||
|
||||
{providerSortingSelected && (
|
||||
<div style={{ marginBottom: -6 }}>
|
||||
<DropdownContainer className="dropdown-container" zIndex={OPENROUTER_MODEL_PICKER_Z_INDEX + 1}>
|
||||
<VSCodeDropdown
|
||||
style={{ width: "100%", marginTop: 3 }}
|
||||
value={apiConfiguration?.openRouterProviderSorting}
|
||||
onChange={(e: any) => {
|
||||
handleFieldChange("openRouterProviderSorting")(e.target.value)
|
||||
}}>
|
||||
<VSCodeOption value="">Default</VSCodeOption>
|
||||
<VSCodeOption value="price">Price</VSCodeOption>
|
||||
<VSCodeOption value="throughput">Throughput</VSCodeOption>
|
||||
<VSCodeOption value="latency">Latency</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</DropdownContainer>
|
||||
<p style={{ fontSize: "12px", marginTop: 3, color: "var(--vscode-descriptionForeground)" }}>
|
||||
{!apiConfiguration?.openRouterProviderSorting &&
|
||||
"Default behavior is to load balance requests across providers (like AWS, Google Vertex, Anthropic), prioritizing price while considering provider uptime"}
|
||||
{apiConfiguration?.openRouterProviderSorting === "price" &&
|
||||
"Sort providers by price, prioritizing the lowest cost provider"}
|
||||
{apiConfiguration?.openRouterProviderSorting === "throughput" &&
|
||||
"Sort providers by throughput, prioritizing the provider with the highest throughput (may increase cost)"}
|
||||
{apiConfiguration?.openRouterProviderSorting === "latency" &&
|
||||
"Sort providers by response time, prioritizing the provider with the lowest latency"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OpenRouterModelPicker isPopup={isPopup} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ModelInfo } from "@shared/api"
|
||||
|
||||
/**
|
||||
* Formats a price as a currency string
|
||||
*/
|
||||
export const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to format token prices for display
|
||||
* @param price The price per million tokens
|
||||
*/
|
||||
export const formatTokenPrice = (price: number) => {
|
||||
return `${formatPrice(price)}/million tokens`
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine if a model supports thinking budget
|
||||
*/
|
||||
export const hasThinkingBudget = (modelInfo: ModelInfo): boolean => {
|
||||
return !!modelInfo.thinkingConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if a model supports images
|
||||
*/
|
||||
export const supportsImages = (modelInfo: ModelInfo): boolean => {
|
||||
return !!modelInfo.supportsImages
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if a model supports browser use
|
||||
*/
|
||||
export const supportsBrowserUse = (modelInfo: ModelInfo): boolean => {
|
||||
return !!modelInfo.supportsImages // browser tool uses image recognition
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if a model supports prompt caching
|
||||
*/
|
||||
export const supportsPromptCache = (modelInfo: ModelInfo): boolean => {
|
||||
return !!modelInfo.supportsPromptCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to format token limits for display
|
||||
*/
|
||||
export const formatTokenLimit = (limit: number): string => {
|
||||
return limit.toLocaleString()
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
ApiConfiguration,
|
||||
ApiProvider,
|
||||
ModelInfo,
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
bedrockDefaultModelId,
|
||||
bedrockModels,
|
||||
deepSeekDefaultModelId,
|
||||
deepSeekModels,
|
||||
geminiDefaultModelId,
|
||||
geminiModels,
|
||||
mistralDefaultModelId,
|
||||
mistralModels,
|
||||
openAiModelInfoSaneDefaults,
|
||||
openAiNativeDefaultModelId,
|
||||
openAiNativeModels,
|
||||
openRouterDefaultModelId,
|
||||
openRouterDefaultModelInfo,
|
||||
requestyDefaultModelId,
|
||||
requestyDefaultModelInfo,
|
||||
mainlandQwenModels,
|
||||
internationalQwenModels,
|
||||
mainlandQwenDefaultModelId,
|
||||
internationalQwenDefaultModelId,
|
||||
vertexDefaultModelId,
|
||||
vertexModels,
|
||||
askSageModels,
|
||||
askSageDefaultModelId,
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
sambanovaModels,
|
||||
sambanovaDefaultModelId,
|
||||
doubaoModels,
|
||||
doubaoDefaultModelId,
|
||||
liteLlmModelInfoSaneDefaults,
|
||||
nebiusModels,
|
||||
nebiusDefaultModelId,
|
||||
cerebrasModels,
|
||||
cerebrasDefaultModelId,
|
||||
sapAiCoreModels,
|
||||
sapAiCoreDefaultModelId,
|
||||
claudeCodeDefaultModelId,
|
||||
claudeCodeModels,
|
||||
} from "@shared/api"
|
||||
|
||||
/**
|
||||
* Interface for normalized API configuration
|
||||
*/
|
||||
export interface NormalizedApiConfig {
|
||||
selectedProvider: ApiProvider
|
||||
selectedModelId: string
|
||||
selectedModelInfo: ModelInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes API configuration to ensure consistent values
|
||||
*/
|
||||
export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration): NormalizedApiConfig {
|
||||
const provider = apiConfiguration?.apiProvider || "anthropic"
|
||||
const modelId = apiConfiguration?.apiModelId
|
||||
|
||||
const getProviderData = (models: Record<string, ModelInfo>, defaultId: string) => {
|
||||
let selectedModelId: string
|
||||
let selectedModelInfo: ModelInfo
|
||||
if (modelId && modelId in models) {
|
||||
selectedModelId = modelId
|
||||
selectedModelInfo = models[modelId]
|
||||
} else {
|
||||
selectedModelId = defaultId
|
||||
selectedModelInfo = models[defaultId]
|
||||
}
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId,
|
||||
selectedModelInfo,
|
||||
}
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
case "claude-code":
|
||||
return getProviderData(claudeCodeModels, claudeCodeDefaultModelId)
|
||||
case "bedrock":
|
||||
if (apiConfiguration?.awsBedrockCustomSelected) {
|
||||
const baseModelId = apiConfiguration.awsBedrockCustomModelBaseId
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: modelId || bedrockDefaultModelId,
|
||||
selectedModelInfo: (baseModelId && bedrockModels[baseModelId]) || bedrockModels[bedrockDefaultModelId],
|
||||
}
|
||||
}
|
||||
return getProviderData(bedrockModels, bedrockDefaultModelId)
|
||||
case "vertex":
|
||||
return getProviderData(vertexModels, vertexDefaultModelId)
|
||||
case "gemini":
|
||||
return getProviderData(geminiModels, geminiDefaultModelId)
|
||||
case "openai-native":
|
||||
return getProviderData(openAiNativeModels, openAiNativeDefaultModelId)
|
||||
case "deepseek":
|
||||
return getProviderData(deepSeekModels, deepSeekDefaultModelId)
|
||||
case "qwen":
|
||||
const qwenModels = apiConfiguration?.qwenApiLine === "china" ? mainlandQwenModels : internationalQwenModels
|
||||
const qwenDefaultId =
|
||||
apiConfiguration?.qwenApiLine === "china" ? mainlandQwenDefaultModelId : internationalQwenDefaultModelId
|
||||
return getProviderData(qwenModels, qwenDefaultId)
|
||||
case "doubao":
|
||||
return getProviderData(doubaoModels, doubaoDefaultModelId)
|
||||
case "mistral":
|
||||
return getProviderData(mistralModels, mistralDefaultModelId)
|
||||
case "asksage":
|
||||
return getProviderData(askSageModels, askSageDefaultModelId)
|
||||
case "openrouter":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo,
|
||||
}
|
||||
case "requesty":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
|
||||
}
|
||||
case "cline":
|
||||
const openRouterModelId = apiConfiguration?.openRouterModelId || openRouterDefaultModelId
|
||||
const openRouterModelInfo = apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: openRouterModelId,
|
||||
// TODO: remove this once we have a better way to handle free models on Cline
|
||||
// Free grok 3 promotion
|
||||
selectedModelInfo:
|
||||
openRouterModelId === "x-ai/grok-3"
|
||||
? { ...openRouterModelInfo, inputPrice: 0, outputPrice: 0 }
|
||||
: openRouterModelInfo,
|
||||
}
|
||||
case "openai":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.openAiModelId || "",
|
||||
selectedModelInfo: apiConfiguration?.openAiModelInfo || openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "ollama":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.ollamaModelId || "",
|
||||
selectedModelInfo: openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "lmstudio":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.lmStudioModelId || "",
|
||||
selectedModelInfo: openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "vscode-lm":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.vsCodeLmModelSelector
|
||||
? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}`
|
||||
: "",
|
||||
selectedModelInfo: {
|
||||
...openAiModelInfoSaneDefaults,
|
||||
supportsImages: false, // VSCode LM API currently doesn't support images
|
||||
},
|
||||
}
|
||||
case "litellm":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.liteLlmModelId || "",
|
||||
selectedModelInfo: apiConfiguration?.liteLlmModelInfo || liteLlmModelInfoSaneDefaults,
|
||||
}
|
||||
case "xai":
|
||||
return getProviderData(xaiModels, xaiDefaultModelId)
|
||||
case "nebius":
|
||||
return getProviderData(nebiusModels, nebiusDefaultModelId)
|
||||
case "sambanova":
|
||||
return getProviderData(sambanovaModels, sambanovaDefaultModelId)
|
||||
case "cerebras":
|
||||
return getProviderData(cerebrasModels, cerebrasDefaultModelId)
|
||||
case "sapaicore":
|
||||
return getProviderData(sapAiCoreModels, sapAiCoreDefaultModelId)
|
||||
default:
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the OpenRouter authentication URL
|
||||
*/
|
||||
export function getOpenRouterAuthUrl(uriScheme?: string) {
|
||||
return `https://openrouter.ai/auth?callback_url=${uriScheme || "vscode"}://saoudrizwan.claude-dev/openrouter`
|
||||
}
|
||||
Reference in New Issue
Block a user