Merge branch 'main' of https://github.com/RooVetGit/Roo-Code into sbc_add_subtasks

This commit is contained in:
ShayBC 2025-02-26 12:49:19 +02:00
commit 4095c16d32
54 changed files with 2598 additions and 1665 deletions

View file

@ -1,5 +1,21 @@
# Roo Code Changelog
## [3.7.5]
- Fix context window truncation math (see [#1173](https://github.com/RooVetGit/Roo-Code/issues/1173))
- Fix various issues with the model picker (thanks @System233!)
- Fix model input / output cost parsing (thanks @System233!)
- Add drag-and-drop for files
- Enable the "Thinking Budget" slider for Claude 3.7 Sonnet on OpenRouter
## [3.7.4]
- Fix a bug that prevented the "Thinking" setting from properly updating when switching profiles.
## [3.7.3]
- Support for ["Thinking"](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) Sonnet 3.7 when using the Anthropic provider.
## [3.7.2]
- Fix computer use and prompt caching for OpenRouter's `anthropic/claude-3.7-sonnet:beta` (thanks @cte!)

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "roo-cline",
"version": "3.7.2",
"version": "3.7.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
"version": "3.7.2",
"version": "3.7.5",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.37.0",

View file

@ -1,9 +1,9 @@
{
"name": "roo-cline",
"displayName": "Roo Code (prev. Roo Cline)",
"description": "An AI-powered autonomous coding agent that lives in your editor.",
"description": "A whole dev team of AI agents in your editor.",
"publisher": "RooVeterinaryInc",
"version": "3.7.2",
"version": "3.7.5",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",

View file

@ -14,8 +14,6 @@ import { ApiStream } from "../transform/stream"
const ANTHROPIC_DEFAULT_TEMPERATURE = 0
const THINKING_MODELS = ["claude-3-7-sonnet-20250219"]
export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
private options: ApiHandlerOptions
private client: Anthropic
@ -32,16 +30,19 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
const modelId = this.getModel().id
const maxTokens = this.getModel().info.maxTokens || 8192
let { id: modelId, info: modelInfo } = this.getModel()
const maxTokens = modelInfo.maxTokens || 8192
let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE
let thinking: BetaThinkingConfigParam | undefined = undefined
if (THINKING_MODELS.includes(modelId)) {
thinking = this.options.anthropicThinking
? { type: "enabled", budget_tokens: this.options.anthropicThinking }
: { type: "disabled" }
// Anthropic "Thinking" models require a temperature of 1.0.
if (modelId === "claude-3-7-sonnet-20250219:thinking") {
// The `:thinking` variant is a virtual identifier for the
// `claude-3-7-sonnet-20250219` model with a thinking budget.
// We can handle this more elegantly in the future.
modelId = "claude-3-7-sonnet-20250219"
const budgetTokens = this.options.anthropicThinking ?? Math.max(maxTokens * 0.8, 1024)
thinking = { type: "enabled", budget_tokens: budgetTokens }
temperature = 1.0
}
@ -114,8 +115,8 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
default: {
stream = (await this.client.messages.create({
model: modelId,
max_tokens: this.getModel().info.maxTokens || 8192,
temperature: this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE,
max_tokens: maxTokens,
temperature,
system: [{ text: systemPrompt, type: "text" }],
messages,
// tools,

View file

@ -1,10 +1,12 @@
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import OpenAI from "openai"
import { ApiHandler, SingleCompletionHandler } from "../"
import { ApiHandlerOptions, ModelInfo, glamaDefaultModelId, glamaDefaultModelInfo } from "../../shared/api"
import { parseApiPrice } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { ApiHandler, SingleCompletionHandler } from "../"
const GLAMA_DEFAULT_TEMPERATURE = 0
@ -69,7 +71,7 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
let maxTokens: number | undefined
if (this.getModel().id.startsWith("anthropic/")) {
maxTokens = 8_192
maxTokens = this.getModel().info.maxTokens
}
const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = {
@ -177,7 +179,7 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
}
if (this.getModel().id.startsWith("anthropic/")) {
requestOptions.max_tokens = 8192
requestOptions.max_tokens = this.getModel().info.maxTokens
}
const response = await this.client.chat.completions.create(requestOptions)
@ -190,3 +192,44 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
}
}
}
export async function getGlamaModels() {
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://glama.ai/api/gateway/v1/models")
const rawModels = response.data
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.maxTokensOutput,
contextWindow: rawModel.maxTokensInput,
supportsImages: rawModel.capabilities?.includes("input:image"),
supportsComputerUse: rawModel.capabilities?.includes("computer_use"),
supportsPromptCache: rawModel.capabilities?.includes("caching"),
inputPrice: parseApiPrice(rawModel.pricePerToken?.input),
outputPrice: parseApiPrice(rawModel.pricePerToken?.output),
description: undefined,
cacheWritesPrice: parseApiPrice(rawModel.pricePerToken?.cacheWrite),
cacheReadsPrice: parseApiPrice(rawModel.pricePerToken?.cacheRead),
}
switch (rawModel.id) {
case rawModel.id.startsWith("anthropic/claude-3-7-sonnet"):
modelInfo.maxTokens = 16384
break
case rawModel.id.startsWith("anthropic/"):
modelInfo.maxTokens = 8192
break
default:
break
}
models[rawModel.id] = modelInfo
}
} catch (error) {
console.error(`Error fetching Glama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
}
return models
}

View file

@ -1,5 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import axios from "axios"
import { ApiHandler, SingleCompletionHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
@ -72,3 +74,17 @@ export class LmStudioHandler implements ApiHandler, SingleCompletionHandler {
}
}
}
export async function getLmStudioModels(baseUrl = "http://localhost:1234") {
try {
if (!URL.canParse(baseUrl)) {
return []
}
const response = await axios.get(`${baseUrl}/v1/models`)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
return [...new Set<string>(modelsArray)]
} catch (error) {
return []
}
}

View file

@ -1,5 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import axios from "axios"
import { ApiHandler, SingleCompletionHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
@ -88,3 +90,17 @@ export class OllamaHandler implements ApiHandler, SingleCompletionHandler {
}
}
}
export async function getOllamaModels(baseUrl = "http://localhost:11434") {
try {
if (!URL.canParse(baseUrl)) {
return []
}
const response = await axios.get(`${baseUrl}/api/tags`)
const modelsArray = response.data?.models?.map((model: any) => model.name) || []
return [...new Set<string>(modelsArray)]
} catch (error) {
return []
}
}

View file

@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI, { AzureOpenAI } from "openai"
import axios from "axios"
import {
ApiHandlerOptions,
@ -166,3 +167,27 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler {
}
}
}
export async function getOpenAiModels(baseUrl?: string, apiKey?: string) {
try {
if (!baseUrl) {
return []
}
if (!URL.canParse(baseUrl)) {
return []
}
const config: Record<string, any> = {}
if (apiKey) {
config["headers"] = { Authorization: `Bearer ${apiKey}` }
}
const response = await axios.get(`${baseUrl}/models`, config)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
return [...new Set<string>(modelsArray)]
} catch (error) {
return []
}
}

View file

@ -1,29 +1,31 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
import axios from "axios"
import OpenAI from "openai"
import { ApiHandler } from "../"
import delay from "delay"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
import { parseApiPrice } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream"
import delay from "delay"
import { convertToR1Format } from "../transform/r1-format"
import { DEEP_SEEK_DEFAULT_TEMPERATURE } from "./openai"
import { ApiHandler, SingleCompletionHandler } from ".."
const OPENROUTER_DEFAULT_TEMPERATURE = 0
// Add custom interface for OpenRouter params
// Add custom interface for OpenRouter params.
type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & {
transforms?: string[]
include_reasoning?: boolean
thinking?: BetaThinkingConfigParam
}
// Add custom interface for OpenRouter usage chunk
// Add custom interface for OpenRouter usage chunk.
interface OpenRouterApiStreamUsageChunk extends ApiStreamUsageChunk {
fullResponseText: string
}
import { SingleCompletionHandler } from ".."
import { convertToR1Format } from "../transform/r1-format"
export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
private options: ApiHandlerOptions
private client: OpenAI
@ -52,22 +54,12 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
...convertToOpenAiMessages(messages),
]
const { id: modelId, info: modelInfo } = this.getModel()
// prompt caching: https://openrouter.ai/docs/prompt-caching
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
switch (this.getModel().id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
switch (true) {
case modelId.startsWith("anthropic/"):
openAiMessages[0] = {
role: "system",
content: [
@ -103,31 +95,11 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
break
}
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
// (models usually default to max tokens allowed)
let maxTokens: number | undefined
switch (this.getModel().id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
maxTokens = 8_192
break
}
let defaultTemperature = OPENROUTER_DEFAULT_TEMPERATURE
let topP: number | undefined = undefined
// Handle models based on deepseek-r1
if (
this.getModel().id.startsWith("deepseek/deepseek-r1") ||
this.getModel().id === "perplexity/sonar-reasoning"
) {
if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") {
// Recommended temperature for DeepSeek reasoning models
defaultTemperature = DEEP_SEEK_DEFAULT_TEMPERATURE
// DeepSeek highly recommends using user instead of system role
@ -136,24 +108,38 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
topP = 0.95
}
let temperature = this.options.modelTemperature ?? defaultTemperature
let thinking: BetaThinkingConfigParam | undefined = undefined
if (modelInfo.thinking) {
const maxTokens = modelInfo.maxTokens || 8192
const budgetTokens = this.options.anthropicThinking ?? Math.max(maxTokens * 0.8, 1024)
thinking = { type: "enabled", budget_tokens: budgetTokens }
temperature = 1.0
}
// https://openrouter.ai/docs/transforms
let fullResponseText = ""
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
max_tokens: maxTokens,
temperature: this.options.modelTemperature ?? defaultTemperature,
const completionParams: OpenRouterChatCompletionParams = {
model: modelId,
max_tokens: modelInfo.maxTokens,
temperature,
thinking, // OpenRouter is temporarily supporting this.
top_p: topP,
messages: openAiMessages,
stream: true,
include_reasoning: true,
// This way, the transforms field will only be included in the parameters when openRouterUseMiddleOutTransform is true.
...(this.options.openRouterUseMiddleOutTransform && { transforms: ["middle-out"] }),
} as OpenRouterChatCompletionParams)
}
const stream = await this.client.chat.completions.create(completionParams)
let genId: string | undefined
for await (const chunk of stream as unknown as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>) {
// openrouter returns an error object instead of the openai sdk throwing an error
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
if ("error" in chunk) {
const error = chunk.error as { message?: string; code?: number }
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
@ -165,12 +151,14 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
}
const delta = chunk.choices[0]?.delta
if ("reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
text: delta.reasoning,
} as ApiStreamChunk
}
if (delta?.content) {
fullResponseText += delta.content
yield {
@ -178,6 +166,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
text: delta.content,
} as ApiStreamChunk
}
// if (chunk.usage) {
// yield {
// type: "usage",
@ -187,10 +176,12 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
// }
}
// retry fetching generation details
// Retry fetching generation details.
let attempt = 0
while (attempt++ < 10) {
await delay(200) // FIXME: necessary delay to ensure generation endpoint is ready
try {
const response = await axios.get(`https://openrouter.ai/api/v1/generation?id=${genId}`, {
headers: {
@ -200,7 +191,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
})
const generation = response.data?.data
console.log("OpenRouter generation details:", response.data)
yield {
type: "usage",
// cacheWriteTokens: 0,
@ -211,6 +202,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
totalCost: generation?.total_cost || 0,
fullResponseText,
} as OpenRouterApiStreamUsageChunk
return
} catch (error) {
// ignore if fails
@ -218,13 +210,13 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
}
}
}
getModel(): { id: string; info: ModelInfo } {
getModel() {
const modelId = this.options.openRouterModelId
const modelInfo = this.options.openRouterModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
}
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
return modelId && modelInfo
? { id: modelId, info: modelInfo }
: { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
}
async completePrompt(prompt: string): Promise<string> {
@ -247,7 +239,81 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
if (error instanceof Error) {
throw new Error(`OpenRouter completion error: ${error.message}`)
}
throw error
}
}
}
export async function getOpenRouterModels() {
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
const rawModels = response.data.data
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.top_provider?.max_completion_tokens,
contextWindow: rawModel.context_length,
supportsImages: rawModel.architecture?.modality?.includes("image"),
supportsPromptCache: false,
inputPrice: parseApiPrice(rawModel.pricing?.prompt),
outputPrice: parseApiPrice(rawModel.pricing?.completion),
description: rawModel.description,
thinking: rawModel.id === "anthropic/claude-3.7-sonnet:thinking",
}
// NOTE: this needs to be synced with api.ts/openrouter default model info.
switch (true) {
case rawModel.id.startsWith("anthropic/claude-3.7-sonnet"):
modelInfo.supportsComputerUse = true
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
modelInfo.maxTokens = 16384
break
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"):
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
modelInfo.maxTokens = 8192
break
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet"):
modelInfo.supportsComputerUse = true
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
modelInfo.maxTokens = 8192
break
case rawModel.id.startsWith("anthropic/claude-3-5-haiku"):
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 1.25
modelInfo.cacheReadsPrice = 0.1
modelInfo.maxTokens = 8192
break
case rawModel.id.startsWith("anthropic/claude-3-opus"):
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 18.75
modelInfo.cacheReadsPrice = 1.5
modelInfo.maxTokens = 8192
break
case rawModel.id.startsWith("anthropic/claude-3-haiku"):
default:
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 0.3
modelInfo.cacheReadsPrice = 0.03
modelInfo.maxTokens = 8192
break
}
models[rawModel.id] = modelInfo
}
} catch (error) {
console.error(
`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
}
return models
}

View file

@ -1,6 +1,9 @@
import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
import axios from "axios"
import { ModelInfo, requestyModelInfoSaneDefaults, requestyDefaultModelId } from "../../shared/api"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { parseApiPrice } from "../../utils/cost"
import { ApiStreamUsageChunk } from "../transform/stream"
import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
export class RequestyHandler extends OpenAiHandler {
constructor(options: OpenAiHandlerOptions) {
@ -38,3 +41,65 @@ export class RequestyHandler extends OpenAiHandler {
}
}
}
export async function getRequestyModels() {
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://router.requesty.ai/v1/models")
const rawModels = response.data.data
for (const rawModel of rawModels) {
// {
// id: "anthropic/claude-3-5-sonnet-20240620",
// object: "model",
// created: 1740552655,
// owned_by: "system",
// input_price: 0.0000028,
// caching_price: 0.00000375,
// cached_price: 3e-7,
// output_price: 0.000015,
// max_output_tokens: 8192,
// context_window: 200000,
// supports_caching: true,
// description:
// "Anthropic's previous most intelligent model. High level of intelligence and capability. Excells in coding.",
// }
const modelInfo: ModelInfo = {
maxTokens: rawModel.max_output_tokens,
contextWindow: rawModel.context_window,
supportsPromptCache: rawModel.supports_caching,
inputPrice: parseApiPrice(rawModel.input_price),
outputPrice: parseApiPrice(rawModel.output_price),
description: rawModel.description,
cacheWritesPrice: parseApiPrice(rawModel.caching_price),
cacheReadsPrice: parseApiPrice(rawModel.cached_price),
}
switch (rawModel.id) {
case rawModel.id.startsWith("anthropic/claude-3-7-sonnet"):
modelInfo.supportsComputerUse = true
modelInfo.supportsImages = true
modelInfo.maxTokens = 16384
break
case rawModel.id.startsWith("anthropic/claude-3-5-sonnet-20241022"):
modelInfo.supportsComputerUse = true
modelInfo.supportsImages = true
modelInfo.maxTokens = 8192
break
case rawModel.id.startsWith("anthropic/"):
modelInfo.maxTokens = 8192
break
default:
break
}
models[rawModel.id] = modelInfo
}
} catch (error) {
console.error(`Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
}
return models
}

View file

@ -1,9 +1,11 @@
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import OpenAI from "openai"
import { ApiHandler, SingleCompletionHandler } from "../"
import { ApiHandlerOptions, ModelInfo, unboundDefaultModelId, unboundDefaultModelInfo } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { ApiHandler, SingleCompletionHandler } from "../"
interface UnboundUsage extends OpenAI.CompletionUsage {
cache_creation_input_tokens?: number
@ -71,7 +73,7 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
let maxTokens: number | undefined
if (this.getModel().id.startsWith("anthropic/")) {
maxTokens = 8_192
maxTokens = this.getModel().info.maxTokens
}
const { data: completion, response } = await this.client.chat.completions
@ -150,7 +152,7 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
}
if (this.getModel().id.startsWith("anthropic/")) {
requestOptions.max_tokens = 8192
requestOptions.max_tokens = this.getModel().info.maxTokens
}
const response = await this.client.chat.completions.create(requestOptions)
@ -163,3 +165,46 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
}
}
}
export async function getUnboundModels() {
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://api.getunbound.ai/models")
if (response.data) {
const rawModels: Record<string, any> = response.data
for (const [modelId, model] of Object.entries(rawModels)) {
const modelInfo: ModelInfo = {
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,
}
switch (true) {
case modelId.startsWith("anthropic/claude-3-7-sonnet"):
modelInfo.maxTokens = 16384
break
case modelId.startsWith("anthropic/"):
modelInfo.maxTokens = 8192
break
default:
break
}
models[modelId] = modelInfo
}
}
} catch (error) {
console.error(`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
}
return models
}

View file

@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { ApiHandler, SingleCompletionHandler } from "../"
import { calculateApiCost } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
@ -545,3 +546,15 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
}
}
}
export async function getVsCodeLmModels() {
try {
const models = await vscode.lm.selectChatModels({})
return models || []
} catch (error) {
console.error(
`Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
return []
}
}

View file

@ -47,6 +47,8 @@ import {
import { getApiMetrics } from "../shared/getApiMetrics"
import { HistoryItem } from "../shared/HistoryItem"
import { ClineAskResponse } from "../shared/WebviewMessage"
import { GlobalFileNames } from "../shared/globalFileNames"
import { defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
import { calculateApiCost } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs"
import { arePathsEqual, getReadablePath } from "../utils/path"
@ -54,12 +56,10 @@ import { parseMentions } from "./mentions"
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
import { formatResponse } from "./prompts/responses"
import { SYSTEM_PROMPT } from "./prompts/system"
import { modes, defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
import { truncateConversationIfNeeded } from "./sliding-window"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
import { ClineProvider } from "./webview/ClineProvider"
import { detectCodeOmission } from "../integrations/editor/detect-omission"
import { BrowserSession } from "../services/browser/BrowserSession"
import { OpenRouterHandler } from "../api/providers/openrouter"
import { McpHub } from "../services/mcp/McpHub"
import crypto from "crypto"
import { insertGroups } from "./diff/insert-groups"

View file

@ -1,3 +1,5 @@
// npx jest src/core/config/__tests__/CustomModesManager.test.ts
import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs/promises"
@ -15,9 +17,10 @@ describe("CustomModesManager", () => {
let mockOnUpdate: jest.Mock
let mockWorkspaceFolders: { uri: { fsPath: string } }[]
const mockStoragePath = "/mock/settings"
// Use path.sep to ensure correct path separators for the current platform
const mockStoragePath = `${path.sep}mock${path.sep}settings`
const mockSettingsPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
const mockRoomodes = "/mock/workspace/.roomodes"
const mockRoomodes = `${path.sep}mock${path.sep}workspace${path.sep}.roomodes`
beforeEach(() => {
mockOnUpdate = jest.fn()
@ -243,7 +246,15 @@ describe("CustomModesManager", () => {
await manager.updateCustomMode("project-mode", projectMode)
// Verify .roomodes was created with the project mode
expect(fs.writeFile).toHaveBeenCalledWith(mockRoomodes, expect.stringContaining("project-mode"), "utf-8")
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String), // Don't check exact path as it may have different separators on different platforms
expect.stringContaining("project-mode"),
"utf-8",
)
// Verify the path is correct regardless of separators
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
expect(path.normalize(writeCall[0])).toBe(path.normalize(mockRoomodes))
// Verify the content written to .roomodes
expect(roomodesContent).toEqual({

View file

@ -5,6 +5,9 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo } from "../../../shared/api"
import { truncateConversation, truncateConversationIfNeeded } from "../index"
/**
* Tests for the truncateConversation function
*/
describe("truncateConversation", () => {
it("should retain the first message", () => {
const messages: Anthropic.Messages.MessageParam[] = [
@ -91,6 +94,86 @@ describe("truncateConversation", () => {
})
})
/**
* Tests for the getMaxTokens function (private but tested through truncateConversationIfNeeded)
*/
describe("getMaxTokens", () => {
// We'll test this indirectly through truncateConversationIfNeeded
const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({
contextWindow,
supportsPromptCache: true, // Not relevant for getMaxTokens
maxTokens,
})
// Reuse across tests for consistency
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "First message" },
{ role: "assistant", content: "Second message" },
{ role: "user", content: "Third message" },
{ role: "assistant", content: "Fourth message" },
{ role: "user", content: "Fifth message" },
]
it("should use maxTokens as buffer when specified", () => {
const modelInfo = createModelInfo(100000, 50000)
// Max tokens = 100000 - 50000 = 50000
// Below max tokens - no truncation
const result1 = truncateConversationIfNeeded(messages, 49999, modelInfo)
expect(result1).toEqual(messages)
// Above max tokens - truncate
const result2 = truncateConversationIfNeeded(messages, 50001, modelInfo)
expect(result2).not.toEqual(messages)
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
})
it("should use 20% of context window as buffer when maxTokens is undefined", () => {
const modelInfo = createModelInfo(100000, undefined)
// Max tokens = 100000 - (100000 * 0.2) = 80000
// Below max tokens - no truncation
const result1 = truncateConversationIfNeeded(messages, 79999, modelInfo)
expect(result1).toEqual(messages)
// Above max tokens - truncate
const result2 = truncateConversationIfNeeded(messages, 80001, modelInfo)
expect(result2).not.toEqual(messages)
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
})
it("should handle small context windows appropriately", () => {
const modelInfo = createModelInfo(50000, 10000)
// Max tokens = 50000 - 10000 = 40000
// Below max tokens - no truncation
const result1 = truncateConversationIfNeeded(messages, 39999, modelInfo)
expect(result1).toEqual(messages)
// Above max tokens - truncate
const result2 = truncateConversationIfNeeded(messages, 40001, modelInfo)
expect(result2).not.toEqual(messages)
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
})
it("should handle large context windows appropriately", () => {
const modelInfo = createModelInfo(200000, 30000)
// Max tokens = 200000 - 30000 = 170000
// Below max tokens - no truncation
const result1 = truncateConversationIfNeeded(messages, 169999, modelInfo)
expect(result1).toEqual(messages)
// Above max tokens - truncate
const result2 = truncateConversationIfNeeded(messages, 170001, modelInfo)
expect(result2).not.toEqual(messages)
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
})
})
/**
* Tests for the truncateConversationIfNeeded function
*/
describe("truncateConversationIfNeeded", () => {
const createModelInfo = (contextWindow: number, supportsPromptCache: boolean, maxTokens?: number): ModelInfo => ({
contextWindow,
@ -106,25 +189,43 @@ describe("truncateConversationIfNeeded", () => {
{ role: "user", content: "Fifth message" },
]
it("should not truncate if tokens are below threshold for prompt caching models", () => {
const modelInfo = createModelInfo(200000, true, 50000)
const totalTokens = 100000 // Below threshold
it("should not truncate if tokens are below max tokens threshold", () => {
const modelInfo = createModelInfo(100000, true, 30000)
const maxTokens = 100000 - 30000 // 70000
const totalTokens = 69999 // Below threshold
const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo)
expect(result).toEqual(messages)
expect(result).toEqual(messages) // No truncation occurs
})
it("should not truncate if tokens are below threshold for non-prompt caching models", () => {
const modelInfo = createModelInfo(200000, false)
const totalTokens = 100000 // Below threshold
it("should truncate if tokens are above max tokens threshold", () => {
const modelInfo = createModelInfo(100000, true, 30000)
const maxTokens = 100000 - 30000 // 70000
const totalTokens = 70001 // Above threshold
// When truncating, always uses 0.5 fraction
// With 4 messages after the first, 0.5 fraction means remove 2 messages
const expectedResult = [messages[0], messages[3], messages[4]]
const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo)
expect(result).toEqual(messages)
expect(result).toEqual(expectedResult)
})
it("should use 80% of context window as threshold if it's greater than (contextWindow - buffer)", () => {
const modelInfo = createModelInfo(50000, true) // Small context window
const totalTokens = 40001 // Above 80% threshold (40000)
const mockResult = [messages[0], messages[3], messages[4]]
const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo)
expect(result).toEqual(mockResult)
it("should work with non-prompt caching models the same as prompt caching models", () => {
// The implementation no longer differentiates between prompt caching and non-prompt caching models
const modelInfo1 = createModelInfo(100000, true, 30000)
const modelInfo2 = createModelInfo(100000, false, 30000)
// Test below threshold
const belowThreshold = 69999
expect(truncateConversationIfNeeded(messages, belowThreshold, modelInfo1)).toEqual(
truncateConversationIfNeeded(messages, belowThreshold, modelInfo2),
)
// Test above threshold
const aboveThreshold = 70001
expect(truncateConversationIfNeeded(messages, aboveThreshold, modelInfo1)).toEqual(
truncateConversationIfNeeded(messages, aboveThreshold, modelInfo2),
)
})
})

View file

@ -28,13 +28,9 @@ export function truncateConversation(
/**
* Conditionally truncates the conversation messages if the total token count exceeds the model's limit.
*
* Depending on whether the model supports prompt caching, different maximum token thresholds
* and truncation fractions are used. If the current total tokens exceed the threshold,
* the conversation is truncated using the appropriate fraction.
*
* @param {Anthropic.Messages.MessageParam[]} messages - The conversation messages.
* @param {number} totalTokens - The total number of tokens in the conversation.
* @param {ModelInfo} modelInfo - Model metadata including context window size and prompt cache support.
* @param {ModelInfo} modelInfo - Model metadata including context window size.
* @returns {Anthropic.Messages.MessageParam[]} The original or truncated conversation messages.
*/
export function truncateConversationIfNeeded(
@ -42,61 +38,16 @@ export function truncateConversationIfNeeded(
totalTokens: number,
modelInfo: ModelInfo,
): Anthropic.Messages.MessageParam[] {
if (modelInfo.supportsPromptCache) {
return totalTokens < getMaxTokensForPromptCachingModels(modelInfo)
? messages
: truncateConversation(messages, getTruncFractionForPromptCachingModels(modelInfo))
} else {
return totalTokens < getMaxTokensForNonPromptCachingModels(modelInfo)
? messages
: truncateConversation(messages, getTruncFractionForNonPromptCachingModels(modelInfo))
}
return totalTokens < getMaxTokens(modelInfo) ? messages : truncateConversation(messages, 0.5)
}
/**
* Calculates the maximum allowed tokens for models that support prompt caching.
*
* The maximum is computed as the greater of (contextWindow - buffer) and 80% of the contextWindow.
* Calculates the maximum allowed tokens
*
* @param {ModelInfo} modelInfo - The model information containing the context window size.
* @returns {number} The maximum number of tokens allowed for prompt caching models.
* @returns {number} The maximum number of tokens allowed
*/
function getMaxTokensForPromptCachingModels(modelInfo: ModelInfo): number {
// The buffer needs to be at least as large as `modelInfo.maxTokens`.
const buffer = modelInfo.maxTokens ? Math.max(40_000, modelInfo.maxTokens) : 40_000
return Math.max(modelInfo.contextWindow - buffer, modelInfo.contextWindow * 0.8)
}
/**
* Provides the fraction of messages to remove for models that support prompt caching.
*
* @param {ModelInfo} modelInfo - The model information (unused in current implementation).
* @returns {number} The truncation fraction for prompt caching models (fixed at 0.5).
*/
function getTruncFractionForPromptCachingModels(modelInfo: ModelInfo): number {
return 0.5
}
/**
* Calculates the maximum allowed tokens for models that do not support prompt caching.
*
* The maximum is computed as the greater of (contextWindow - 40000) and 80% of the contextWindow.
*
* @param {ModelInfo} modelInfo - The model information containing the context window size.
* @returns {number} The maximum number of tokens allowed for non-prompt caching models.
*/
function getMaxTokensForNonPromptCachingModels(modelInfo: ModelInfo): number {
// The buffer needs to be at least as large as `modelInfo.maxTokens`.
const buffer = modelInfo.maxTokens ? Math.max(40_000, modelInfo.maxTokens) : 40_000
return Math.max(modelInfo.contextWindow - buffer, modelInfo.contextWindow * 0.8)
}
/**
* Provides the fraction of messages to remove for models that do not support prompt caching.
*
* @param {ModelInfo} modelInfo - The model information.
* @returns {number} The truncation fraction for non-prompt caching models (fixed at 0.1).
*/
function getTruncFractionForNonPromptCachingModels(modelInfo: ModelInfo): number {
return Math.min(40_000 / modelInfo.contextWindow, 0.2)
function getMaxTokens(modelInfo: ModelInfo): number {
// The buffer needs to be at least as large as `modelInfo.maxTokens`, or 20% of the context window if for some reason it's not set.
return modelInfo.contextWindow - (modelInfo.maxTokens || modelInfo.contextWindow * 0.2)
}

View file

@ -8,138 +8,51 @@ import * as path from "path"
import * as vscode from "vscode"
import simpleGit from "simple-git"
import { buildApiHandler } from "../../api"
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array"
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import type { SecretKey, GlobalStateKey } from "../../shared/globalState"
import { HistoryItem } from "../../shared/HistoryItem"
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
import { downloadTask } from "../../integrations/misc/export-markdown"
import { openFile, openImage } from "../../integrations/misc/open-file"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import { getDiffStrategy } from "../diff/DiffStrategy"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array"
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem"
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
import { SYSTEM_PROMPT } from "../prompts/system"
import { McpServerManager } from "../../services/mcp/McpServerManager"
import { fileExistsAtPath } from "../../utils/fs"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { singleCompletionHandler } from "../../utils/single-completion-handler"
import { searchCommits } from "../../utils/git"
import { getDiffStrategy } from "../diff/DiffStrategy"
import { SYSTEM_PROMPT } from "../prompts/system"
import { ConfigManager } from "../config/ConfigManager"
import { CustomModesManager } from "../config/CustomModesManager"
import { buildApiHandler } from "../../api"
import { getOpenRouterModels } from "../../api/providers/openrouter"
import { getGlamaModels } from "../../api/providers/glama"
import { getUnboundModels } from "../../api/providers/unbound"
import { getRequestyModels } from "../../api/providers/requesty"
import { getOpenAiModels } from "../../api/providers/openai"
import { getOllamaModels } from "../../api/providers/ollama"
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
import { getLmStudioModels } from "../../api/providers/lmstudio"
import { ACTION_NAMES } from "../CodeActionProvider"
import { Cline } from "../Cline"
import { openMention } from "../mentions"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { singleCompletionHandler } from "../../utils/single-completion-handler"
import { searchCommits } from "../../utils/git"
import { ConfigManager } from "../config/ConfigManager"
import { CustomModesManager } from "../config/CustomModesManager"
import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
import { ACTION_NAMES } from "../CodeActionProvider"
import { McpServerManager } from "../../services/mcp/McpServerManager"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
*/
type SecretKey =
| "apiKey"
| "glamaApiKey"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
| "awsSessionToken"
| "openAiApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
| "mistralApiKey"
| "unboundApiKey"
| "requestyApiKey"
type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "glamaModelId"
| "glamaModelInfo"
| "awsRegion"
| "awsUseCrossRegionInference"
| "awsProfile"
| "awsUseProfile"
| "vertexProjectId"
| "vertexRegion"
| "lastShownAnnouncementId"
| "customInstructions"
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowExecute"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
| "openAiCustomModelInfo"
| "openAiUseAzure"
| "ollamaModelId"
| "ollamaBaseUrl"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "anthropicThinking"
| "azureApiVersion"
| "openAiStreamingEnabled"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterBaseUrl"
| "openRouterUseMiddleOutTransform"
| "allowedCommands"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "checkpointsEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "fuzzyMatchThreshold"
| "preferredLanguage" // Language setting for Cline's communication
| "writeDelayMs"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "enableMcpServerCreation"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "rateLimitSeconds"
| "currentApiConfigName"
| "listApiConfigMeta"
| "vsCodeLmModelSelector"
| "mode"
| "modeApiConfigs"
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"
| "experiments" // Map of experiment IDs to their enabled state
| "autoApprovalEnabled"
| "customModes" // Array of custom modes
| "unboundModelId"
| "requestyModelId"
| "requestyModelInfo"
| "unboundModelInfo"
| "modelTemperature"
| "mistralCodestralUrl"
| "maxOpenTabsContext"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
glamaModels: "glama_models.json",
openRouterModels: "openrouter_models.json",
requestyModels: "requesty_models.json",
mcpSettings: "cline_mcp_settings.json",
unboundModels: "unbound_models.json",
}
/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
* https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
*/
export class ClineProvider implements vscode.WebviewViewProvider {
public static readonly sideBarId = "roo-cline.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
@ -702,15 +615,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.postStateToWebview()
this.workspaceTracker?.initializeFilePaths() // don't await
getTheme().then((theme) =>
this.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }),
)
// post last cached models in case the call to endpoint fails
this.readOpenRouterModels().then((openRouterModels) => {
if (openRouterModels) {
this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
}
})
// If MCP Hub is already initialized, update the webview with current server list
if (this.mcpHub) {
@ -720,13 +628,37 @@ export class ClineProvider implements vscode.WebviewViewProvider {
})
}
// gui relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
// we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
// (see normalizeApiConfiguration > openrouter)
this.refreshOpenRouterModels().then(async (openRouterModels) => {
const cacheDir = await this.ensureCacheDirectoryExists()
// Post last cached models in case the call to endpoint fails.
this.readModelsFromCache(GlobalFileNames.openRouterModels).then((openRouterModels) => {
if (openRouterModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
}
})
// GUI relies on model info to be up-to-date to provide
// the most accurate pricing, so we need to fetch the
// latest details on launch.
// We do this for all users since many users switch
// between api providers and if they were to switch back
// to OpenRouter it would be showing outdated model info
// if we hadn't retrieved the latest at this point
// (see normalizeApiConfiguration > openrouter).
getOpenRouterModels().then(async (openRouterModels) => {
if (Object.keys(openRouterModels).length > 0) {
await fs.writeFile(
path.join(cacheDir, GlobalFileNames.openRouterModels),
JSON.stringify(openRouterModels),
)
await this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
// Update model info in state (this needs to be
// done here since we don't want to update state
// while settings is open, and we may refresh
// models there).
const { apiConfiguration } = await this.getState()
if (apiConfiguration.openRouterModelId) {
await this.updateGlobalState(
"openRouterModelInfo",
@ -736,15 +668,23 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
})
this.readGlamaModels().then((glamaModels) => {
this.readModelsFromCache(GlobalFileNames.glamaModels).then((glamaModels) => {
if (glamaModels) {
this.postMessageToWebview({ type: "glamaModels", glamaModels })
}
})
this.refreshGlamaModels().then(async (glamaModels) => {
if (glamaModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
getGlamaModels().then(async (glamaModels) => {
if (Object.keys(glamaModels).length > 0) {
await fs.writeFile(
path.join(cacheDir, GlobalFileNames.glamaModels),
JSON.stringify(glamaModels),
)
await this.postMessageToWebview({ type: "glamaModels", glamaModels })
const { apiConfiguration } = await this.getState()
if (apiConfiguration.glamaModelId) {
await this.updateGlobalState(
"glamaModelInfo",
@ -755,14 +695,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
})
this.readUnboundModels().then((unboundModels) => {
this.readModelsFromCache(GlobalFileNames.unboundModels).then((unboundModels) => {
if (unboundModels) {
this.postMessageToWebview({ type: "unboundModels", unboundModels })
}
})
this.refreshUnboundModels().then(async (unboundModels) => {
if (unboundModels) {
getUnboundModels().then(async (unboundModels) => {
if (Object.keys(unboundModels).length > 0) {
await fs.writeFile(
path.join(cacheDir, GlobalFileNames.unboundModels),
JSON.stringify(unboundModels),
)
await this.postMessageToWebview({ type: "unboundModels", unboundModels })
const { apiConfiguration } = await this.getState()
if (apiConfiguration?.unboundModelId) {
await this.updateGlobalState(
"unboundModelInfo",
@ -773,15 +721,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
})
this.readRequestyModels().then((requestyModels) => {
this.readModelsFromCache(GlobalFileNames.requestyModels).then((requestyModels) => {
if (requestyModels) {
this.postMessageToWebview({ type: "requestyModels", requestyModels })
}
})
this.refreshRequestyModels().then(async (requestyModels) => {
if (requestyModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
getRequestyModels().then(async (requestyModels) => {
if (Object.keys(requestyModels).length > 0) {
await fs.writeFile(
path.join(cacheDir, GlobalFileNames.requestyModels),
JSON.stringify(requestyModels),
)
await this.postMessageToWebview({ type: "requestyModels", requestyModels })
const { apiConfiguration } = await this.getState()
if (apiConfiguration.requestyModelId) {
await this.updateGlobalState(
"requestyModelInfo",
@ -928,41 +883,82 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "resetState":
await this.resetState()
break
case "requestOllamaModels":
const ollamaModels = await this.getOllamaModels(message.text)
this.postMessageToWebview({ type: "ollamaModels", ollamaModels })
break
case "requestLmStudioModels":
const lmStudioModels = await this.getLmStudioModels(message.text)
this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels })
break
case "requestVsCodeLmModels":
const vsCodeLmModels = await this.getVsCodeLmModels()
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
case "refreshOpenRouterModels":
const openRouterModels = await getOpenRouterModels()
if (Object.keys(openRouterModels).length > 0) {
const cacheDir = await this.ensureCacheDirectoryExists()
await fs.writeFile(
path.join(cacheDir, GlobalFileNames.openRouterModels),
JSON.stringify(openRouterModels),
)
await this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
}
break
case "refreshGlamaModels":
await this.refreshGlamaModels()
const glamaModels = await getGlamaModels()
if (Object.keys(glamaModels).length > 0) {
const cacheDir = await this.ensureCacheDirectoryExists()
await fs.writeFile(
path.join(cacheDir, GlobalFileNames.glamaModels),
JSON.stringify(glamaModels),
)
await this.postMessageToWebview({ type: "glamaModels", glamaModels })
}
break
case "refreshOpenRouterModels":
await this.refreshOpenRouterModels()
case "refreshUnboundModels":
const unboundModels = await getUnboundModels()
if (Object.keys(unboundModels).length > 0) {
const cacheDir = await this.ensureCacheDirectoryExists()
await fs.writeFile(
path.join(cacheDir, GlobalFileNames.unboundModels),
JSON.stringify(unboundModels),
)
await this.postMessageToWebview({ type: "unboundModels", unboundModels })
}
break
case "refreshRequestyModels":
const requestyModels = await getRequestyModels()
if (Object.keys(requestyModels).length > 0) {
const cacheDir = await this.ensureCacheDirectoryExists()
await fs.writeFile(
path.join(cacheDir, GlobalFileNames.requestyModels),
JSON.stringify(requestyModels),
)
await this.postMessageToWebview({ type: "requestyModels", requestyModels })
}
break
case "refreshOpenAiModels":
if (message?.values?.baseUrl && message?.values?.apiKey) {
const openAiModels = await this.getOpenAiModels(
const openAiModels = await getOpenAiModels(
message?.values?.baseUrl,
message?.values?.apiKey,
)
this.postMessageToWebview({ type: "openAiModels", openAiModels })
}
break
case "refreshUnboundModels":
await this.refreshUnboundModels()
case "requestOllamaModels":
const ollamaModels = await getOllamaModels(message.text)
// TODO: Cache like we do for OpenRouter, etc?
this.postMessageToWebview({ type: "ollamaModels", ollamaModels })
break
case "refreshRequestyModels":
if (message?.values?.apiKey) {
const requestyModels = await this.refreshRequestyModels(message?.values?.apiKey)
this.postMessageToWebview({ type: "requestyModels", requestyModels: requestyModels })
}
case "requestLmStudioModels":
const lmStudioModels = await getLmStudioModels(message.text)
// TODO: Cache like we do for OpenRouter, etc?
this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels })
break
case "requestVsCodeLmModels":
const vsCodeLmModels = await getVsCodeLmModels()
// TODO: Cache like we do for OpenRouter, etc?
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
break
case "openImage":
openImage(message.text!)
@ -1883,159 +1879,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return settingsDir
}
// Ollama
async getOllamaModels(baseUrl?: string) {
try {
if (!baseUrl) {
baseUrl = "http://localhost:11434"
}
if (!URL.canParse(baseUrl)) {
return []
}
const response = await axios.get(`${baseUrl}/api/tags`)
const modelsArray = response.data?.models?.map((model: any) => model.name) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
return []
}
private async ensureCacheDirectoryExists() {
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
// LM Studio
private async readModelsFromCache(filename: string): Promise<Record<string, ModelInfo> | undefined> {
const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
const fileExists = await fileExistsAtPath(filePath)
async getLmStudioModels(baseUrl?: string) {
try {
if (!baseUrl) {
baseUrl = "http://localhost:1234"
}
if (!URL.canParse(baseUrl)) {
return []
}
const response = await axios.get(`${baseUrl}/v1/models`)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
return []
}
}
// VSCode LM API
private async getVsCodeLmModels() {
try {
const models = await vscode.lm.selectChatModels({})
return models || []
} catch (error) {
this.outputChannel.appendLine(
`Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
return []
}
}
// OpenAi
async getOpenAiModels(baseUrl?: string, apiKey?: string) {
try {
if (!baseUrl) {
return []
}
if (!URL.canParse(baseUrl)) {
return []
}
const config: Record<string, any> = {}
if (apiKey) {
config["headers"] = { Authorization: `Bearer ${apiKey}` }
}
const response = await axios.get(`${baseUrl}/models`, config)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
return []
}
}
// Requesty
async readRequestyModels(): Promise<Record<string, ModelInfo> | undefined> {
const requestyModelsFilePath = path.join(
await this.ensureCacheDirectoryExists(),
GlobalFileNames.requestyModels,
)
const fileExists = await fileExistsAtPath(requestyModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(requestyModelsFilePath, "utf8")
const fileContents = await fs.readFile(filePath, "utf8")
return JSON.parse(fileContents)
}
return undefined
}
async refreshRequestyModels(apiKey?: string) {
const requestyModelsFilePath = path.join(
await this.ensureCacheDirectoryExists(),
GlobalFileNames.requestyModels,
)
const models: Record<string, ModelInfo> = {}
try {
const config: Record<string, any> = {}
if (!apiKey) {
apiKey = (await this.getSecret("requestyApiKey")) as string
}
if (!apiKey) {
this.outputChannel.appendLine("No Requesty API key found")
return models
}
if (apiKey) {
config["headers"] = { Authorization: `Bearer ${apiKey}` }
}
const response = await axios.get("https://router.requesty.ai/v1/models", config)
if (response.data) {
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.max_output_tokens,
contextWindow: rawModel.context_window,
supportsImages: rawModel.support_image,
supportsComputerUse: rawModel.support_computer_use,
supportsPromptCache: rawModel.supports_caching,
inputPrice: parsePrice(rawModel.input_price),
outputPrice: parsePrice(rawModel.output_price),
description: rawModel.description,
cacheWritesPrice: parsePrice(rawModel.caching_price),
cacheReadsPrice: parsePrice(rawModel.cached_price),
}
models[rawModel.id] = modelInfo
}
} else {
this.outputChannel.appendLine("Invalid response from Requesty API")
}
await fs.writeFile(requestyModelsFilePath, JSON.stringify(models))
} catch (error) {
this.outputChannel.appendLine(
`Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
}
await this.postMessageToWebview({ type: "requestyModels", requestyModels: models })
return models
}
// OpenRouter
async handleOpenRouterCallback(code: string) {
@ -2064,11 +1925,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
}
private async ensureCacheDirectoryExists(): Promise<string> {
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
// Glama
async handleGlamaCallback(code: string) {
let apiKey: string
@ -2099,225 +1956,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
}
private async readModelsFromCache(filename: string): Promise<Record<string, ModelInfo> | undefined> {
const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
const fileContents = await fs.readFile(filePath, "utf8")
return JSON.parse(fileContents)
}
return undefined
}
async readGlamaModels(): Promise<Record<string, ModelInfo> | undefined> {
return this.readModelsFromCache(GlobalFileNames.glamaModels)
}
async refreshGlamaModels() {
const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://glama.ai/api/gateway/v1/models")
/*
{
"added": "2024-12-24T15:12:49.324Z",
"capabilities": [
"adjustable_safety_settings",
"caching",
"code_execution",
"function_calling",
"json_mode",
"json_schema",
"system_instructions",
"tuning",
"input:audio",
"input:image",
"input:text",
"input:video",
"output:text"
],
"id": "google-vertex/gemini-1.5-flash-002",
"maxTokensInput": 1048576,
"maxTokensOutput": 8192,
"pricePerToken": {
"cacheRead": null,
"cacheWrite": null,
"input": "0.000000075",
"output": "0.0000003"
}
}
*/
if (response.data) {
const rawModels = response.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.maxTokensOutput,
contextWindow: rawModel.maxTokensInput,
supportsImages: rawModel.capabilities?.includes("input:image"),
supportsComputerUse: rawModel.capabilities?.includes("computer_use"),
supportsPromptCache: rawModel.capabilities?.includes("caching"),
inputPrice: parsePrice(rawModel.pricePerToken?.input),
outputPrice: parsePrice(rawModel.pricePerToken?.output),
description: undefined,
cacheWritesPrice: parsePrice(rawModel.pricePerToken?.cacheWrite),
cacheReadsPrice: parsePrice(rawModel.pricePerToken?.cacheRead),
}
models[rawModel.id] = modelInfo
}
} else {
this.outputChannel.appendLine("Invalid response from Glama API")
}
await fs.writeFile(glamaModelsFilePath, JSON.stringify(models))
} catch (error) {
this.outputChannel.appendLine(
`Error fetching Glama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
}
await this.postMessageToWebview({ type: "glamaModels", glamaModels: models })
return models
}
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
return this.readModelsFromCache(GlobalFileNames.openRouterModels)
}
async refreshOpenRouterModels() {
const openRouterModelsFilePath = path.join(
await this.ensureCacheDirectoryExists(),
GlobalFileNames.openRouterModels,
)
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
if (response.data?.data) {
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.top_provider?.max_completion_tokens,
contextWindow: rawModel.context_length,
supportsImages: rawModel.architecture?.modality?.includes("image"),
supportsPromptCache: false,
inputPrice: parsePrice(rawModel.pricing?.prompt),
outputPrice: parsePrice(rawModel.pricing?.completion),
description: rawModel.description,
}
switch (rawModel.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
// NOTE: this needs to be synced with api.ts/openrouter default model info.
modelInfo.supportsComputerUse = true
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
case "anthropic/claude-3.5-haiku":
case "anthropic/claude-3.5-haiku:beta":
case "anthropic/claude-3.5-haiku-20241022":
case "anthropic/claude-3.5-haiku-20241022:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 1.25
modelInfo.cacheReadsPrice = 0.1
break
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 18.75
modelInfo.cacheReadsPrice = 1.5
break
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 0.3
modelInfo.cacheReadsPrice = 0.03
break
}
models[rawModel.id] = modelInfo
}
} else {
this.outputChannel.appendLine("Invalid response from OpenRouter API")
}
await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
} catch (error) {
this.outputChannel.appendLine(
`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
}
await this.postMessageToWebview({ type: "openRouterModels", openRouterModels: models })
return models
}
async readUnboundModels(): Promise<Record<string, ModelInfo> | undefined> {
return this.readModelsFromCache(GlobalFileNames.unboundModels)
}
async refreshUnboundModels() {
const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
const models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://api.getunbound.ai/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))
} 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<{
@ -2460,6 +2098,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
@ -2506,6 +2146,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
experiments: experiments ?? experimentDefault,
mcpServers: this.mcpHub?.getAllServers() ?? [],
maxOpenTabsContext: maxOpenTabsContext ?? 20,
cwd: cwd,
}
}
@ -2879,26 +2520,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return await this.context.globalState.get(key)
}
// workspace
private async updateWorkspaceState(key: string, value: any) {
await this.context.workspaceState.update(key, value)
}
private async getWorkspaceState(key: string) {
return await this.context.workspaceState.get(key)
}
// private async clearState() {
// this.context.workspaceState.keys().forEach((key) => {
// this.context.workspaceState.update(key, undefined)
// })
// this.context.globalState.keys().forEach((key) => {
// this.context.globalState.update(key, undefined)
// })
// this.context.secrets.delete("apiKey")
// }
// secrets
public async storeSecret(key: SecretKey, value?: string) {

View file

@ -14,7 +14,9 @@ import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { GlobalFileNames } from "../../shared/globalFileNames"
import {
McpResource,
McpResourceResponse,

View file

@ -27,10 +27,11 @@ export interface ExtensionMessage {
| "workspaceUpdated"
| "invoke"
| "partialMessage"
| "glamaModels"
| "openRouterModels"
| "openAiModels"
| "glamaModels"
| "unboundModels"
| "requestyModels"
| "openAiModels"
| "mcpServers"
| "enhancedPrompt"
| "commitSearchResults"
@ -43,8 +44,6 @@ export interface ExtensionMessage {
| "autoApprovalEnabled"
| "updateCustomMode"
| "deleteCustomMode"
| "unboundModels"
| "refreshUnboundModels"
| "currentCheckpointUpdated"
text?: string
action?:
@ -67,11 +66,11 @@ export interface ExtensionMessage {
path?: string
}>
partialMessage?: ClineMessage
glamaModels?: Record<string, ModelInfo>
requestyModels?: Record<string, ModelInfo>
openRouterModels?: Record<string, ModelInfo>
openAiModels?: string[]
glamaModels?: Record<string, ModelInfo>
unboundModels?: Record<string, ModelInfo>
requestyModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[]
@ -129,6 +128,7 @@ export interface ExtensionState {
customModes: ModeConfig[]
toolRequirements?: Record<string, boolean> // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled)
maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
cwd?: string // Current working directory
}
export interface ClineMessage {

View file

@ -40,11 +40,11 @@ export interface WebviewMessage {
| "openFile"
| "openMention"
| "cancelTask"
| "refreshGlamaModels"
| "refreshOpenRouterModels"
| "refreshOpenAiModels"
| "refreshGlamaModels"
| "refreshUnboundModels"
| "refreshRequestyModels"
| "refreshOpenAiModels"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
@ -71,7 +71,6 @@ export interface WebviewMessage {
| "mcpEnabled"
| "enableMcpServerCreation"
| "searchCommits"
| "refreshGlamaModels"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "rateLimitSeconds"

View file

@ -89,6 +89,13 @@ export interface ModelInfo {
cacheReadsPrice?: number
description?: string
reasoningEffort?: "low" | "medium" | "high"
thinking?: boolean
}
export const THINKING_BUDGET = {
step: 1024,
min: 1024,
default: 8 * 1024,
}
// Anthropic
@ -96,8 +103,8 @@ export interface ModelInfo {
export type AnthropicModelId = keyof typeof anthropicModels
export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219"
export const anthropicModels = {
"claude-3-7-sonnet-20250219": {
maxTokens: 64_000,
"claude-3-7-sonnet-20250219:thinking": {
maxTokens: 16384,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
@ -106,6 +113,19 @@ export const anthropicModels = {
outputPrice: 15.0, // $15 per million output tokens
cacheWritesPrice: 3.75, // $3.75 per million tokens
cacheReadsPrice: 0.3, // $0.30 per million tokens
thinking: true,
},
"claude-3-7-sonnet-20250219": {
maxTokens: 16384,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 3.0, // $3 per million input tokens
outputPrice: 15.0, // $15 per million output tokens
cacheWritesPrice: 3.75, // $3.75 per million tokens
cacheReadsPrice: 0.3, // $0.30 per million tokens
thinking: false,
},
"claude-3-5-sonnet-20241022": {
maxTokens: 8192,

View file

@ -0,0 +1,9 @@
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
glamaModels: "glama_models.json",
openRouterModels: "openrouter_models.json",
requestyModels: "requesty_models.json",
mcpSettings: "cline_mcp_settings.json",
unboundModels: "unbound_models.json",
}

85
src/shared/globalState.ts Normal file
View file

@ -0,0 +1,85 @@
export type SecretKey =
| "apiKey"
| "glamaApiKey"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
| "awsSessionToken"
| "openAiApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
| "mistralApiKey"
| "unboundApiKey"
| "requestyApiKey"
export type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "glamaModelId"
| "glamaModelInfo"
| "awsRegion"
| "awsUseCrossRegionInference"
| "awsProfile"
| "awsUseProfile"
| "vertexProjectId"
| "vertexRegion"
| "lastShownAnnouncementId"
| "customInstructions"
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowExecute"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
| "openAiCustomModelInfo"
| "openAiUseAzure"
| "ollamaModelId"
| "ollamaBaseUrl"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "anthropicThinking"
| "azureApiVersion"
| "openAiStreamingEnabled"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterBaseUrl"
| "openRouterUseMiddleOutTransform"
| "allowedCommands"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "checkpointsEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "fuzzyMatchThreshold"
| "preferredLanguage" // Language setting for Cline's communication
| "writeDelayMs"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "enableMcpServerCreation"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "rateLimitSeconds"
| "currentApiConfigName"
| "listApiConfigMeta"
| "vsCodeLmModelSelector"
| "mode"
| "modeApiConfigs"
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"
| "experiments" // Map of experiment IDs to their enabled state
| "autoApprovalEnabled"
| "customModes" // Array of custom modes
| "unboundModelId"
| "requestyModelId"
| "requestyModelInfo"
| "unboundModelInfo"
| "modelTemperature"
| "mistralCodestralUrl"
| "maxOpenTabsContext"

View file

@ -1,6 +1,9 @@
import { arePathsEqual, getReadablePath } from "../path"
import * as path from "path"
// npx jest src/utils/__tests__/path.test.ts
import os from "os"
import * as path from "path"
import { arePathsEqual, getReadablePath } from "../path"
describe("Path Utilities", () => {
const originalPlatform = process.platform
@ -92,22 +95,24 @@ describe("Path Utilities", () => {
describe("getReadablePath", () => {
const homeDir = os.homedir()
const desktop = path.join(homeDir, "Desktop")
const cwd = process.platform === "win32" ? "C:\\Users\\test\\project" : "/Users/test/project"
it("should return basename when path equals cwd", () => {
const cwd = "/Users/test/project"
expect(getReadablePath(cwd, cwd)).toBe("project")
})
it("should return relative path when inside cwd", () => {
const cwd = "/Users/test/project"
const filePath = "/Users/test/project/src/file.txt"
const filePath =
process.platform === "win32"
? "C:\\Users\\test\\project\\src\\file.txt"
: "/Users/test/project/src/file.txt"
expect(getReadablePath(cwd, filePath)).toBe("src/file.txt")
})
it("should return absolute path when outside cwd", () => {
const cwd = "/Users/test/project"
const filePath = "/Users/test/other/file.txt"
expect(getReadablePath(cwd, filePath)).toBe("/Users/test/other/file.txt")
const filePath =
process.platform === "win32" ? "C:\\Users\\test\\other\\file.txt" : "/Users/test/other/file.txt"
expect(getReadablePath(cwd, filePath)).toBe(filePath.toPosix())
})
it("should handle Desktop as cwd", () => {
@ -116,19 +121,20 @@ describe("Path Utilities", () => {
})
it("should handle undefined relative path", () => {
const cwd = "/Users/test/project"
expect(getReadablePath(cwd)).toBe("project")
})
it("should handle parent directory traversal", () => {
const cwd = "/Users/test/project"
const filePath = "../../other/file.txt"
expect(getReadablePath(cwd, filePath)).toBe("/Users/other/file.txt")
const filePath =
process.platform === "win32" ? "C:\\Users\\test\\other\\file.txt" : "/Users/test/other/file.txt"
expect(getReadablePath(cwd, filePath)).toBe(filePath.toPosix())
})
it("should normalize paths with redundant segments", () => {
const cwd = "/Users/test/project"
const filePath = "/Users/test/project/./src/../src/file.txt"
const filePath =
process.platform === "win32"
? "C:\\Users\\test\\project\\src\\file.txt"
: "/Users/test/project/./src/../src/file.txt"
expect(getReadablePath(cwd, filePath)).toBe("src/file.txt")
})
})

View file

@ -22,3 +22,5 @@ export function calculateApiCost(
const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
return totalCost
}
export const parseApiPrice = (price: any) => (price ? parseFloat(price) * 1_000_000 : undefined)

View file

@ -3674,6 +3674,7 @@
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz",
"integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
@ -4719,6 +4720,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},

View file

@ -0,0 +1,6 @@
import React from "react"
export const Check = () => React.createElement("div")
export const ChevronsUpDown = () => React.createElement("div")
export const Loader = () => React.createElement("div")
export const X = () => React.createElement("div")

View file

@ -8,6 +8,9 @@ export const Dropdown = ({ children, value, onChange }: any) =>
export const Pane = ({ children }: any) => React.createElement("div", { "data-testid": "mock-pane" }, children)
export const Button = ({ children, ...props }: any) =>
React.createElement("div", { "data-testid": "mock-button", ...props }, children)
export type DropdownOption = {
label: string
value: string

View file

@ -16,6 +16,7 @@ import { vscode } from "../../utils/vscode"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
import { Mode, getAllModes } from "../../../../src/shared/modes"
import { CaretIcon } from "../common/CaretIcon"
import { convertToMentionPath } from "../../utils/path-mentions"
interface ChatTextAreaProps {
inputValue: string
@ -50,7 +51,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState()
const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes, cwd } = useExtensionState()
const [gitCommits, setGitCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false)
@ -589,18 +590,24 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const files = Array.from(e.dataTransfer.files)
const text = e.dataTransfer.getData("text")
if (text) {
const newValue = inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition)
// Convert the path to a mention-friendly format
const mentionText = convertToMentionPath(text, cwd)
const newValue =
inputValue.slice(0, cursorPosition) + mentionText + " " + inputValue.slice(cursorPosition)
setInputValue(newValue)
const newCursorPosition = cursorPosition + text.length
const newCursorPosition = cursorPosition + mentionText.length + 1
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
return
}
const acceptedTypes = ["png", "jpeg", "webp"]
const imageFiles = files.filter((file) => {
const [type, subtype] = file.type.split("/")
return type === "image" && acceptedTypes.includes(subtype)
})
if (!shouldDisableImages && imageFiles.length > 0) {
const imagePromises = imageFiles.map((file) => {
return new Promise<string | null>((resolve) => {

View file

@ -880,9 +880,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const placeholderText = useMemo(() => {
const baseText = task ? "Type a message..." : "Type your task here..."
const contextText = "(@ to add context, / to switch modes"
const imageText = shouldDisableImages ? "" : ", hold shift to drag in images"
const helpText = imageText ? `\n${contextText}${imageText})` : `\n${contextText})`
return baseText + helpText
const imageText = shouldDisableImages ? "hold shift to drag in files" : ", hold shift to drag in files/images"
return baseText + `\n${contextText}${imageText})`
}, [task, shouldDisableImages])
const itemContent = useCallback(

View file

@ -354,7 +354,7 @@ const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
<Button variant="ghost" size="sm" onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
<span className="codicon codicon-cloud-download" />
</Button>
{item?.size && (
{!!item?.size && item.size > 0 && (
<Button
variant="ghost"
size="sm"

View file

@ -0,0 +1,16 @@
import React from "react"
interface ApiErrorMessageProps {
errorMessage: string | undefined
children?: React.ReactNode
}
export const ApiErrorMessage = ({ errorMessage, children }: ApiErrorMessageProps) => (
<div className="flex flex-col gap-2 text-vscode-errorForeground text-sm">
<div className="flex flex-row items-center gap-1">
<div className="codicon codicon-close" />
<div>{errorMessage}</div>
</div>
{children}
</div>
)

View file

@ -1,11 +1,9 @@
import { memo, useCallback, useMemo, useState } from "react"
import React, { memo, useCallback, useEffect, useMemo, useState } from "react"
import { useDebounce, useEvent } from "react-use"
import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import * as vscodemodels from "vscode"
import { Slider } from "@/components/ui"
import {
ApiConfiguration,
ModelInfo,
@ -38,45 +36,73 @@ import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { vscode } from "../../utils/vscode"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
import { OpenRouterModelPicker } from "./OpenRouterModelPicker"
import OpenAiModelPicker from "./OpenAiModelPicker"
import { GlamaModelPicker } from "./GlamaModelPicker"
import { UnboundModelPicker } from "./UnboundModelPicker"
import { ModelInfoView } from "./ModelInfoView"
import { DROPDOWN_Z_INDEX } from "./styles"
import { RequestyModelPicker } from "./RequestyModelPicker"
import { ModelPicker } from "./ModelPicker"
import { TemperatureControl } from "./TemperatureControl"
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
import { ApiErrorMessage } from "./ApiErrorMessage"
import { ThinkingBudget } from "./ThinkingBudget"
const modelsByProvider: Record<string, Record<string, ModelInfo>> = {
anthropic: anthropicModels,
bedrock: bedrockModels,
vertex: vertexModels,
gemini: geminiModels,
"openai-native": openAiNativeModels,
deepseek: deepSeekModels,
mistral: mistralModels,
}
interface ApiOptionsProps {
uriScheme: string | undefined
apiConfiguration: ApiConfiguration | undefined
apiConfiguration: ApiConfiguration
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
apiErrorMessage?: string
modelIdErrorMessage?: string
fromWelcomeView?: boolean
errorMessage: string | undefined
setErrorMessage: React.Dispatch<React.SetStateAction<string | undefined>>
}
const ApiOptions = ({
uriScheme,
apiConfiguration,
setApiConfigurationField,
apiErrorMessage,
modelIdErrorMessage,
fromWelcomeView,
errorMessage,
setErrorMessage,
}: ApiOptionsProps) => {
const [ollamaModels, setOllamaModels] = useState<string[]>([])
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
[glamaDefaultModelId]: glamaDefaultModelInfo,
})
const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
[unboundDefaultModelId]: unboundDefaultModelInfo,
})
const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
[requestyDefaultModelId]: requestyDefaultModelInfo,
})
const [openAiModels, setOpenAiModels] = useState<Record<string, ModelInfo> | null>(null)
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
const [anthropicThinkingBudget, setAnthropicThinkingBudget] = useState(apiConfiguration?.anthropicThinking)
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const inputEventTransform = <E,>(event: E) => (event as { target: HTMLInputElement })?.target?.value as any
const noTransform = <T,>(value: T) => value
const inputEventTransform = <E,>(event: E) => (event as { target: HTMLInputElement })?.target?.value as any
const dropdownEventTransform = <T,>(event: DropdownOption | string | undefined) =>
(typeof event == "string" ? event : event?.value) as T
const handleInputChange = useCallback(
<K extends keyof ApiConfiguration, E>(
field: K,
@ -88,15 +114,32 @@ const ApiOptions = ({
[setApiConfigurationField],
)
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(
() => normalizeApiConfiguration(apiConfiguration),
[apiConfiguration],
)
// Pull ollama/lmstudio models
// Debounced model updates, only executed 250ms after the user stops typing
// Debounced refresh model updates, only executed 250ms after the user
// stops typing.
useDebounce(
() => {
if (selectedProvider === "ollama") {
if (selectedProvider === "openrouter") {
vscode.postMessage({ type: "refreshOpenRouterModels" })
} else if (selectedProvider === "glama") {
vscode.postMessage({ type: "refreshGlamaModels" })
} else if (selectedProvider === "unbound") {
vscode.postMessage({ type: "refreshUnboundModels" })
} else if (selectedProvider === "requesty") {
vscode.postMessage({
type: "refreshRequestyModels",
values: { apiKey: apiConfiguration?.requestyApiKey },
})
} else if (selectedProvider === "openai") {
vscode.postMessage({
type: "refreshOpenAiModels",
values: { baseUrl: apiConfiguration?.openAiBaseUrl, apiKey: apiConfiguration?.openAiApiKey },
})
} else if (selectedProvider === "ollama") {
vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl })
} else if (selectedProvider === "lmstudio") {
vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl })
@ -105,49 +148,95 @@ const ApiOptions = ({
}
},
250,
[selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl],
[
selectedProvider,
apiConfiguration?.requestyApiKey,
apiConfiguration?.openAiBaseUrl,
apiConfiguration?.openAiApiKey,
apiConfiguration?.ollamaBaseUrl,
apiConfiguration?.lmStudioBaseUrl,
],
)
const handleMessage = useCallback((event: MessageEvent) => {
useEffect(() => {
const apiValidationResult =
validateApiConfiguration(apiConfiguration) ||
validateModelId(apiConfiguration, glamaModels, openRouterModels, unboundModels, requestyModels)
setErrorMessage(apiValidationResult)
}, [apiConfiguration, glamaModels, openRouterModels, setErrorMessage, unboundModels, requestyModels])
const onMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
if (message.type === "ollamaModels" && Array.isArray(message.ollamaModels)) {
const newModels = message.ollamaModels
setOllamaModels(newModels)
} else if (message.type === "lmStudioModels" && Array.isArray(message.lmStudioModels)) {
const newModels = message.lmStudioModels
setLmStudioModels(newModels)
} else if (message.type === "vsCodeLmModels" && Array.isArray(message.vsCodeLmModels)) {
const newModels = message.vsCodeLmModels
setVsCodeLmModels(newModels)
switch (message.type) {
case "openRouterModels": {
const updatedModels = message.openRouterModels ?? {}
setOpenRouterModels({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, ...updatedModels })
break
}
case "glamaModels": {
const updatedModels = message.glamaModels ?? {}
setGlamaModels({ [glamaDefaultModelId]: glamaDefaultModelInfo, ...updatedModels })
break
}
case "unboundModels": {
const updatedModels = message.unboundModels ?? {}
setUnboundModels({ [unboundDefaultModelId]: unboundDefaultModelInfo, ...updatedModels })
break
}
case "requestyModels": {
const updatedModels = message.requestyModels ?? {}
setRequestyModels({ [requestyDefaultModelId]: requestyDefaultModelInfo, ...updatedModels })
break
}
case "openAiModels": {
const updatedModels = message.openAiModels ?? []
setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults])))
break
}
case "ollamaModels":
{
const newModels = message.ollamaModels ?? []
setOllamaModels(newModels)
}
break
case "lmStudioModels":
{
const newModels = message.lmStudioModels ?? []
setLmStudioModels(newModels)
}
break
case "vsCodeLmModels":
{
const newModels = message.vsCodeLmModels ?? []
setVsCodeLmModels(newModels)
}
break
}
}, [])
useEvent("message", handleMessage)
const createDropdown = (models: Record<string, ModelInfo>) => {
const options: DropdownOption[] = [
{ value: "", label: "Select a model..." },
...Object.keys(models).map((modelId) => ({
value: modelId,
label: modelId,
})),
]
return (
<Dropdown
id="model-id"
value={selectedModelId}
onChange={(value) => {
setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value)
}}
style={{ width: "100%" }}
options={options}
/>
)
}
useEvent("message", onMessage)
const selectedProviderModelOptions: DropdownOption[] = useMemo(
() =>
modelsByProvider[selectedProvider]
? [
{ value: "", label: "Select a model..." },
...Object.keys(modelsByProvider[selectedProvider]).map((modelId) => ({
value: modelId,
label: modelId,
})),
]
: [],
[selectedProvider],
)
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<div className="dropdown-container">
<label htmlFor="api-provider">
<span style={{ fontWeight: 500 }}>API Provider</span>
<label htmlFor="api-provider" className="font-medium">
API Provider
</label>
<Dropdown
id="api-provider"
@ -174,6 +263,8 @@ const ApiOptions = ({
/>
</div>
{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
{selectedProvider === "anthropic" && (
<div>
<VSCodeTextField
@ -182,7 +273,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("apiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Anthropic API Key</span>
<span className="font-medium">Anthropic API Key</span>
</VSCodeTextField>
<Checkbox
@ -233,7 +324,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("glamaApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Glama API Key</span>
<span className="font-medium">Glama API Key</span>
</VSCodeTextField>
{!apiConfiguration?.glamaApiKey && (
<VSCodeButtonLink
@ -262,7 +353,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("requestyApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Requesty API Key</span>
<span className="font-medium">Requesty API Key</span>
</VSCodeTextField>
<p
style={{
@ -283,7 +374,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("openAiNativeApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>OpenAI API Key</span>
<span className="font-medium">OpenAI API Key</span>
</VSCodeTextField>
<p
style={{
@ -311,7 +402,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("mistralApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Mistral API Key</span>
<span className="font-medium">Mistral API Key</span>
</VSCodeTextField>
<p
style={{
@ -340,7 +431,7 @@ const ApiOptions = ({
type="url"
onInput={handleInputChange("mistralCodestralUrl")}
placeholder="Default: https://codestral.mistral.ai">
<span style={{ fontWeight: 500 }}>Codestral Base URL (Optional)</span>
<span className="font-medium">Codestral Base URL (Optional)</span>
</VSCodeTextField>
<p
style={{
@ -363,7 +454,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("openRouterApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
<span className="font-medium">OpenRouter API Key</span>
</VSCodeTextField>
{!apiConfiguration?.openRouterApiKey && (
<p>
@ -435,7 +526,7 @@ const ApiOptions = ({
style={{ width: "100%" }}
onInput={handleInputChange("awsProfile")}
placeholder="Enter profile name">
<span style={{ fontWeight: 500 }}>AWS Profile Name</span>
<span className="font-medium">AWS Profile Name</span>
</VSCodeTextField>
) : (
<>
@ -446,7 +537,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("awsAccessKey")}
placeholder="Enter Access Key...">
<span style={{ fontWeight: 500 }}>AWS Access Key</span>
<span className="font-medium">AWS Access Key</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.awsSecretKey || ""}
@ -454,7 +545,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("awsSecretKey")}
placeholder="Enter Secret Key...">
<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
<span className="font-medium">AWS Secret Key</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.awsSessionToken || ""}
@ -462,13 +553,13 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("awsSessionToken")}
placeholder="Enter Session Token...">
<span style={{ fontWeight: 500 }}>AWS Session Token</span>
<span className="font-medium">AWS Session Token</span>
</VSCodeTextField>
</>
)}
<div className="dropdown-container">
<label htmlFor="aws-region-dropdown">
<span style={{ fontWeight: 500 }}>AWS Region</span>
<span className="font-medium">AWS Region</span>
</label>
<Dropdown
id="aws-region-dropdown"
@ -520,11 +611,11 @@ const ApiOptions = ({
style={{ width: "100%" }}
onInput={handleInputChange("vertexProjectId")}
placeholder="Enter Project ID...">
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
<span className="font-medium">Google Cloud Project ID</span>
</VSCodeTextField>
<div className="dropdown-container">
<label htmlFor="vertex-region-dropdown">
<span style={{ fontWeight: 500 }}>Google Cloud Region</span>
<span className="font-medium">Google Cloud Region</span>
</label>
<Dropdown
id="vertex-region-dropdown"
@ -572,7 +663,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("geminiApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Gemini API Key</span>
<span className="font-medium">Gemini API Key</span>
</VSCodeTextField>
<p
style={{
@ -600,7 +691,7 @@ const ApiOptions = ({
type="url"
onInput={handleInputChange("openAiBaseUrl")}
placeholder={"Enter base URL..."}>
<span style={{ fontWeight: 500 }}>Base URL</span>
<span className="font-medium">Base URL</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.openAiApiKey || ""}
@ -608,9 +699,19 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("openAiApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>API Key</span>
<span className="font-medium">API Key</span>
</VSCodeTextField>
<OpenAiModelPicker />
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId="gpt-4o"
defaultModelInfo={openAiModelInfoSaneDefaults}
models={openAiModels}
modelIdKey="openAiModelId"
modelInfoKey="openAiCustomModelInfo"
serviceName="OpenAI"
serviceUrl="https://platform.openai.com"
/>
<div style={{ display: "flex", alignItems: "center" }}>
<Checkbox
checked={apiConfiguration?.openAiStreamingEnabled ?? true}
@ -642,12 +743,7 @@ const ApiOptions = ({
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
/>
)}
<div
style={{
marginTop: 15,
}}
/>
<div className="mt-4" />
<Pane
title="Model Configuration"
open={false}
@ -698,7 +794,7 @@ const ApiOptions = ({
})(),
}}
title="Maximum number of tokens the model can generate in a single response"
onChange={handleInputChange("openAiCustomModelInfo", (e) => {
onInput={handleInputChange("openAiCustomModelInfo", (e) => {
const value = parseInt((e.target as HTMLInputElement).value)
return {
...(apiConfiguration?.openAiCustomModelInfo ||
@ -707,7 +803,7 @@ const ApiOptions = ({
}
})}
placeholder="e.g. 4096">
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
<span className="font-medium">Max Output Tokens</span>
</VSCodeTextField>
<div
style={{
@ -745,7 +841,7 @@ const ApiOptions = ({
})(),
}}
title="Total number of tokens (input + output) the model can process in a single request"
onChange={handleInputChange("openAiCustomModelInfo", (e) => {
onInput={handleInputChange("openAiCustomModelInfo", (e) => {
const value = (e.target as HTMLInputElement).value
const parsed = parseInt(value)
return {
@ -757,7 +853,7 @@ const ApiOptions = ({
}
})}
placeholder="e.g. 128000">
<span style={{ fontWeight: 500 }}>Context Window Size</span>
<span className="font-medium">Context Window Size</span>
</VSCodeTextField>
<div
style={{
@ -795,7 +891,7 @@ const ApiOptions = ({
supportsImages: checked,
}
})}>
<span style={{ fontWeight: 500 }}>Image Support</span>
<span className="font-medium">Image Support</span>
</Checkbox>
<i
className="codicon codicon-info"
@ -839,7 +935,7 @@ const ApiOptions = ({
supportsComputerUse: checked,
}
})}>
<span style={{ fontWeight: 500 }}>Computer Use</span>
<span className="font-medium">Computer Use</span>
</Checkbox>
<i
className="codicon codicon-info"
@ -891,9 +987,9 @@ const ApiOptions = ({
: "var(--vscode-errorForeground)"
})(),
}}
onChange={handleInputChange("openAiCustomModelInfo", (e) => {
onInput={handleInputChange("openAiCustomModelInfo", (e) => {
const value = (e.target as HTMLInputElement).value
const parsed = parseInt(value)
const parsed = parseFloat(value)
return {
...(apiConfiguration?.openAiCustomModelInfo ??
openAiModelInfoSaneDefaults),
@ -904,7 +1000,7 @@ const ApiOptions = ({
})}
placeholder="e.g. 0.0001">
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{ fontWeight: 500 }}>Input Price</span>
<span className="font-medium">Input Price</span>
<i
className="codicon codicon-info"
title="Cost per million tokens in the input/prompt. This affects the cost of sending context and instructions to the model."
@ -936,9 +1032,9 @@ const ApiOptions = ({
: "var(--vscode-errorForeground)"
})(),
}}
onChange={handleInputChange("openAiCustomModelInfo", (e) => {
onInput={handleInputChange("openAiCustomModelInfo", (e) => {
const value = (e.target as HTMLInputElement).value
const parsed = parseInt(value)
const parsed = parseFloat(value)
return {
...(apiConfiguration?.openAiCustomModelInfo ||
openAiModelInfoSaneDefaults),
@ -949,7 +1045,7 @@ const ApiOptions = ({
})}
placeholder="e.g. 0.0002">
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{ fontWeight: 500 }}>Output Price</span>
<span className="font-medium">Output Price</span>
<i
className="codicon codicon-info"
title="Cost per million tokens in the model's response. This affects the cost of generated content and completions."
@ -973,18 +1069,6 @@ const ApiOptions = ({
/>
{/* end Model Info Configuration */}
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
<span style={{ color: "var(--vscode-errorForeground)" }}>
(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
with Claude models. Less capable models may not work as expected.)
</span>
</p>
</div>
)}
@ -996,14 +1080,14 @@ const ApiOptions = ({
type="url"
onInput={handleInputChange("lmStudioBaseUrl")}
placeholder={"Default: http://localhost:1234"}>
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
<span className="font-medium">Base URL (optional)</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.lmStudioModelId || ""}
style={{ width: "100%" }}
onInput={handleInputChange("lmStudioModelId")}
placeholder={"e.g. meta-llama-3.1-8b-instruct"}>
<span style={{ fontWeight: 500 }}>Model ID</span>
<span className="font-medium">Model ID</span>
</VSCodeTextField>
{lmStudioModels.length > 0 && (
<VSCodeRadioGroup
@ -1042,7 +1126,7 @@ const ApiOptions = ({
</VSCodeLink>{" "}
feature to use it with this extension.{" "}
<span style={{ color: "var(--vscode-errorForeground)" }}>
(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
(<span className="font-medium">Note:</span> Roo Code uses complex prompts and works best
with Claude models. Less capable models may not work as expected.)
</span>
</p>
@ -1057,7 +1141,7 @@ const ApiOptions = ({
type="password"
onInput={handleInputChange("deepSeekApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>DeepSeek API Key</span>
<span className="font-medium">DeepSeek API Key</span>
</VSCodeTextField>
<p
style={{
@ -1081,7 +1165,7 @@ const ApiOptions = ({
<div>
<div className="dropdown-container">
<label htmlFor="vscode-lm-model">
<span style={{ fontWeight: 500 }}>Language Model</span>
<span className="font-medium">Language Model</span>
</label>
{vsCodeLmModels.length > 0 ? (
<Dropdown
@ -1140,15 +1224,21 @@ const ApiOptions = ({
type="url"
onInput={handleInputChange("ollamaBaseUrl")}
placeholder={"Default: http://localhost:11434"}>
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
<span className="font-medium">Base URL (optional)</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.ollamaModelId || ""}
style={{ width: "100%" }}
onInput={handleInputChange("ollamaModelId")}
placeholder={"e.g. llama3.1"}>
<span style={{ fontWeight: 500 }}>Model ID</span>
<span className="font-medium">Model ID</span>
</VSCodeTextField>
{errorMessage && (
<div className="text-vscode-errorForeground text-sm">
<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
{errorMessage}
</div>
)}
{ollamaModels.length > 0 && (
<VSCodeRadioGroup
value={
@ -1181,7 +1271,7 @@ const ApiOptions = ({
quickstart guide.
</VSCodeLink>
<span style={{ color: "var(--vscode-errorForeground)" }}>
(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
(<span className="font-medium">Note:</span> Roo Code uses complex prompts and works best
with Claude models. Less capable models may not work as expected.)
</span>
</p>
@ -1196,7 +1286,7 @@ const ApiOptions = ({
type="password"
onChange={handleInputChange("unboundApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Unbound API Key</span>
<span className="font-medium">Unbound API Key</span>
</VSCodeTextField>
{!apiConfiguration?.unboundApiKey && (
<VSCodeButtonLink
@ -1214,89 +1304,93 @@ const ApiOptions = ({
}}>
This key is stored locally and only used to make API requests from this extension.
</p>
<UnboundModelPicker />
</div>
)}
{apiErrorMessage && (
<p
style={{
margin: "-10px 0 4px 0",
fontSize: 12,
color: "var(--vscode-errorForeground)",
}}>
<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
{apiErrorMessage}
</p>
{selectedProvider === "openrouter" && (
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId={openRouterDefaultModelId}
defaultModelInfo={openRouterDefaultModelInfo}
models={openRouterModels}
modelIdKey="openRouterModelId"
modelInfoKey="openRouterModelInfo"
serviceName="OpenRouter"
serviceUrl="https://openrouter.ai/models"
/>
)}
{selectedProvider === "glama" && <GlamaModelPicker />}
{selectedProvider === "openrouter" && <OpenRouterModelPicker />}
{selectedProvider === "requesty" && <RequestyModelPicker />}
{selectedProvider === "glama" && (
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId={glamaDefaultModelId}
defaultModelInfo={glamaDefaultModelInfo}
models={glamaModels}
modelInfoKey="glamaModelInfo"
modelIdKey="glamaModelId"
serviceName="Glama"
serviceUrl="https://glama.ai/models"
/>
)}
{selectedProvider !== "glama" &&
selectedProvider !== "openrouter" &&
selectedProvider !== "requesty" &&
selectedProvider !== "openai" &&
selectedProvider !== "ollama" &&
selectedProvider !== "lmstudio" &&
selectedProvider !== "unbound" && (
<>
<div className="dropdown-container">
<label htmlFor="model-id">
<span style={{ fontWeight: 500 }}>Model</span>
</label>
{selectedProvider === "anthropic" && createDropdown(anthropicModels)}
{selectedProvider === "bedrock" && createDropdown(bedrockModels)}
{selectedProvider === "vertex" && createDropdown(vertexModels)}
{selectedProvider === "gemini" && createDropdown(geminiModels)}
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
{selectedProvider === "mistral" && createDropdown(mistralModels)}
</div>
{selectedProvider === "unbound" && (
<ModelPicker
apiConfiguration={apiConfiguration}
defaultModelId={unboundDefaultModelId}
defaultModelInfo={unboundDefaultModelInfo}
models={unboundModels}
modelInfoKey="unboundModelInfo"
modelIdKey="unboundModelId"
serviceName="Unbound"
serviceUrl="https://api.getunbound.ai/models"
setApiConfigurationField={setApiConfigurationField}
/>
)}
<ModelInfoView
selectedModelId={selectedModelId}
modelInfo={selectedModelInfo}
isDescriptionExpanded={isDescriptionExpanded}
setIsDescriptionExpanded={setIsDescriptionExpanded}
{selectedProvider === "requesty" && (
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId={requestyDefaultModelId}
defaultModelInfo={requestyDefaultModelInfo}
models={requestyModels}
modelIdKey="requestyModelId"
modelInfoKey="requestyModelInfo"
serviceName="Requesty"
serviceUrl="https://requesty.ai"
/>
)}
{selectedProviderModelOptions.length > 0 && (
<>
<div className="dropdown-container">
<label htmlFor="model-id" className="font-medium">
Model
</label>
<Dropdown
id="model-id"
value={selectedModelId}
onChange={(value) => {
setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value)
}}
options={selectedProviderModelOptions}
className="w-full"
/>
</>
)}
{selectedProvider === "anthropic" && selectedModelId === "claude-3-7-sonnet-20250219" && (
<div className="flex flex-col gap-2 mt-2">
<Checkbox
checked={!!anthropicThinkingBudget}
onChange={(checked) => {
const budget = checked ? 16_384 : undefined
setAnthropicThinkingBudget(budget)
setApiConfigurationField("anthropicThinking", budget)
}}>
Thinking?
</Checkbox>
{anthropicThinkingBudget && (
<>
<div className="text-muted-foreground text-sm">
Number of tokens Claude is allowed to use for its internal reasoning process.
</div>
<div className="flex items-center gap-2">
<Slider
min={1024}
max={anthropicModels["claude-3-7-sonnet-20250219"].maxTokens - 1}
step={1024}
value={[anthropicThinkingBudget]}
onValueChange={(value) => {
const budget = value[0]
setAnthropicThinkingBudget(budget)
setApiConfigurationField("anthropicThinking", budget)
}}
/>
<div className="w-10">{anthropicThinkingBudget}</div>
</div>
</>
)}
</div>
</div>
<ThinkingBudget
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
/>
<ModelInfoView
selectedModelId={selectedModelId}
modelInfo={selectedModelInfo}
isDescriptionExpanded={isDescriptionExpanded}
setIsDescriptionExpanded={setIsDescriptionExpanded}
/>
</>
)}
{!fromWelcomeView && (
@ -1308,18 +1402,6 @@ const ApiOptions = ({
/>
</div>
)}
{modelIdErrorMessage && (
<p
style={{
margin: "-10px 0 4px 0",
fontSize: 12,
color: "var(--vscode-errorForeground)",
}}>
<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
{modelIdErrorMessage}
</p>
)}
</div>
)
}
@ -1341,6 +1423,7 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
const getProviderData = (models: Record<string, ModelInfo>, defaultId: string) => {
let selectedModelId: string
let selectedModelInfo: ModelInfo
if (modelId && modelId in models) {
selectedModelId = modelId
selectedModelInfo = models[modelId]
@ -1348,8 +1431,10 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
selectedModelId = defaultId
selectedModelInfo = models[defaultId]
}
return { selectedProvider: provider, selectedModelId, selectedModelInfo }
}
switch (provider) {
case "anthropic":
return getProviderData(anthropicModels, anthropicDefaultModelId)
@ -1363,12 +1448,6 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
return getProviderData(deepSeekModels, deepSeekDefaultModelId)
case "openai-native":
return getProviderData(openAiNativeModels, openAiNativeDefaultModelId)
case "glama":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.glamaModelId || glamaDefaultModelId,
selectedModelInfo: apiConfiguration?.glamaModelInfo || glamaDefaultModelInfo,
}
case "mistral":
return getProviderData(mistralModels, mistralDefaultModelId)
case "openrouter":
@ -1377,6 +1456,24 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo,
}
case "glama":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.glamaModelId || glamaDefaultModelId,
selectedModelInfo: apiConfiguration?.glamaModelInfo || glamaDefaultModelInfo,
}
case "unbound":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
}
case "requesty":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
}
case "openai":
return {
selectedProvider: provider,
@ -1403,21 +1500,9 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
: "",
selectedModelInfo: {
...openAiModelInfoSaneDefaults,
supportsImages: false, // VSCode LM API currently doesn't support images
supportsImages: false, // VSCode LM API currently doesn't support images.
},
}
case "unbound":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
}
case "requesty":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
}
default:
return getProviderData(anthropicModels, anthropicDefaultModelId)
}

View file

@ -1,15 +0,0 @@
import { ModelPicker } from "./ModelPicker"
import { glamaDefaultModelId } from "../../../../src/shared/api"
export const GlamaModelPicker = () => (
<ModelPicker
defaultModelId={glamaDefaultModelId}
modelsKey="glamaModels"
configKey="glamaModelId"
infoKey="glamaModelInfo"
refreshMessageType="refreshGlamaModels"
serviceName="Glama"
serviceUrl="https://glama.ai/models"
recommendedModel="anthropic/claude-3-7-sonnet"
/>
)

View file

@ -1,186 +1,95 @@
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { useMemo, useState, useCallback, useEffect, useRef } from "react"
import { useMount } from "react-use"
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { cn } from "@/lib/utils"
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem } from "@/components/ui/combobox"
import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { normalizeApiConfiguration } from "./ApiOptions"
import { ThinkingBudget } from "./ThinkingBudget"
import { ModelInfoView } from "./ModelInfoView"
type ModelProvider = "glama" | "openRouter" | "unbound" | "requesty" | "openAi"
type ExtractType<T> = NonNullable<
{ [K in keyof ApiConfiguration]: Required<ApiConfiguration>[K] extends T ? K : never }[keyof ApiConfiguration]
>
type ModelKeys<T extends ModelProvider> = `${T}Models`
type ConfigKeys<T extends ModelProvider> = `${T}ModelId`
type InfoKeys<T extends ModelProvider> = `${T}ModelInfo`
type RefreshMessageType<T extends ModelProvider> = `refresh${Capitalize<T>}Models`
type ModelIdKeys = NonNullable<
{ [K in keyof ApiConfiguration]: K extends `${string}ModelId` ? K : never }[keyof ApiConfiguration]
>
interface ModelPickerProps<T extends ModelProvider = ModelProvider> {
interface ModelPickerProps {
defaultModelId: string
modelsKey: ModelKeys<T>
configKey: ConfigKeys<T>
infoKey: InfoKeys<T>
refreshMessageType: RefreshMessageType<T>
refreshValues?: Record<string, any>
defaultModelInfo?: ModelInfo
models: Record<string, ModelInfo> | null
modelIdKey: ModelIdKeys
modelInfoKey: ExtractType<ModelInfo>
serviceName: string
serviceUrl: string
recommendedModel: string
allowCustomModel?: boolean
apiConfiguration: ApiConfiguration
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
}
export const ModelPicker = ({
defaultModelId,
modelsKey,
configKey,
infoKey,
refreshMessageType,
refreshValues,
models,
modelIdKey,
modelInfoKey,
serviceName,
serviceUrl,
recommendedModel,
allowCustomModel = false,
apiConfiguration,
setApiConfigurationField,
defaultModelInfo,
}: ModelPickerProps) => {
const [customModelId, setCustomModelId] = useState("")
const [isCustomModel, setIsCustomModel] = useState(false)
const [open, setOpen] = useState(false)
const [value, setValue] = useState(defaultModelId)
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const prevRefreshValuesRef = useRef<Record<string, any> | undefined>()
const isInitialized = useRef(false)
const { apiConfiguration, [modelsKey]: models, onUpdateApiConfig, setApiConfiguration } = useExtensionState()
const modelIds = useMemo(
() => (Array.isArray(models) ? models : Object.keys(models)).sort((a, b) => a.localeCompare(b)),
[models],
)
const modelIds = useMemo(() => Object.keys(models ?? {}).sort((a, b) => a.localeCompare(b)), [models])
const { selectedModelId, selectedModelInfo } = useMemo(
() => normalizeApiConfiguration(apiConfiguration),
[apiConfiguration],
)
const onSelectCustomModel = useCallback(
(modelId: string) => {
setCustomModelId(modelId)
const modelInfo = { id: modelId }
const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setValue(modelId)
setOpen(false)
setIsCustomModel(false)
},
[apiConfiguration, configKey, infoKey, onUpdateApiConfig, setApiConfiguration],
)
const onSelect = useCallback(
(modelId: string) => {
const modelInfo = Array.isArray(models)
? { id: modelId } // For OpenAI models which are just strings
: models[modelId] // For other models that have full info objects
const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setValue(modelId)
setOpen(false)
const modelInfo = models?.[modelId]
setApiConfigurationField(modelIdKey, modelId)
setApiConfigurationField(modelInfoKey, modelInfo ?? defaultModelInfo)
},
[apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration],
[modelIdKey, modelInfoKey, models, setApiConfigurationField, defaultModelInfo],
)
const debouncedRefreshModels = useMemo(() => {
return debounce(() => {
const message = refreshValues
? { type: refreshMessageType, values: refreshValues }
: { type: refreshMessageType }
vscode.postMessage(message)
}, 100)
}, [refreshMessageType, refreshValues])
useMount(() => {
debouncedRefreshModels()
return () => debouncedRefreshModels.clear()
})
const inputValue = apiConfiguration[modelIdKey]
useEffect(() => {
if (!refreshValues) {
prevRefreshValuesRef.current = undefined
return
if (!inputValue && !isInitialized.current) {
const initialValue = modelIds.includes(selectedModelId) ? selectedModelId : defaultModelId
setApiConfigurationField(modelIdKey, initialValue)
}
// Check if all values in refreshValues are truthy
if (Object.values(refreshValues).some((value) => !value)) {
prevRefreshValuesRef.current = undefined
return
}
// Compare with previous values
const prevValues = prevRefreshValuesRef.current
if (prevValues && JSON.stringify(prevValues) === JSON.stringify(refreshValues)) {
return
}
prevRefreshValuesRef.current = refreshValues
debouncedRefreshModels()
}, [debouncedRefreshModels, refreshValues])
useEffect(() => setValue(selectedModelId), [selectedModelId])
isInitialized.current = true
}, [inputValue, modelIds, setApiConfigurationField, modelIdKey, selectedModelId, defaultModelId])
return (
<>
<div className="font-semibold">Model</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="combobox" role="combobox" aria-expanded={open} className="w-full justify-between">
{value ?? "Select model..."}
<CaretSortIcon className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0">
<Command>
<CommandInput placeholder="Search model..." className="h-9" />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{modelIds.map((model) => (
<CommandItem key={model} value={model} onSelect={onSelect}>
{model}
<CheckIcon
className={cn("ml-auto", value === model ? "opacity-100" : "opacity-0")}
/>
</CommandItem>
))}
</CommandGroup>
{allowCustomModel && (
<CommandGroup heading="Custom">
<CommandItem
onSelect={() => {
setIsCustomModel(true)
setOpen(false)
}}>
+ Add custom model
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedModelId && selectedModelInfo && (
<Combobox type="single" inputValue={inputValue} onInputValueChange={onSelect}>
<ComboboxInput placeholder="Search model..." data-testid="model-input" />
<ComboboxContent>
<ComboboxEmpty>No model found.</ComboboxEmpty>
{modelIds.map((model) => (
<ComboboxItem key={model} value={model}>
{model}
</ComboboxItem>
))}
</ComboboxContent>
</Combobox>
<ThinkingBudget
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
/>
{selectedModelId && selectedModelInfo && selectedModelId === inputValue && (
<ModelInfoView
selectedModelId={selectedModelId}
modelInfo={selectedModelInfo}
@ -194,31 +103,9 @@ export const ModelPicker = ({
{serviceName}.
</VSCodeLink>
If you're unsure which model to choose, Roo Code works best with{" "}
<VSCodeLink onClick={() => onSelect(recommendedModel)}>{recommendedModel}.</VSCodeLink>
<VSCodeLink onClick={() => onSelect(defaultModelId)}>{defaultModelId}.</VSCodeLink>
You can also try searching "free" for no-cost options currently available.
</p>
{allowCustomModel && isCustomModel && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--vscode-editor-background)] p-6 rounded-lg w-96">
<h3 className="text-lg font-semibold mb-4">Add Custom Model</h3>
<input
type="text"
className="w-full p-2 mb-4 bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] border border-[var(--vscode-input-border)] rounded"
placeholder="Enter model ID"
value={customModelId}
onChange={(e) => setCustomModelId(e.target.value)}
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setIsCustomModel(false)}>
Cancel
</Button>
<Button onClick={() => onSelectCustomModel(customModelId)} disabled={!customModelId.trim()}>
Add
</Button>
</div>
</div>
</div>
)}
</>
)
}

View file

@ -1,27 +0,0 @@
import React from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { ModelPicker } from "./ModelPicker"
const OpenAiModelPicker: React.FC = () => {
const { apiConfiguration } = useExtensionState()
return (
<ModelPicker
defaultModelId={apiConfiguration?.openAiModelId || ""}
modelsKey="openAiModels"
configKey="openAiModelId"
infoKey="openAiModelInfo"
refreshMessageType="refreshOpenAiModels"
refreshValues={{
baseUrl: apiConfiguration?.openAiBaseUrl,
apiKey: apiConfiguration?.openAiApiKey,
}}
serviceName="OpenAI"
serviceUrl="https://platform.openai.com"
recommendedModel="gpt-4-turbo-preview"
allowCustomModel={true}
/>
)
}
export default OpenAiModelPicker

View file

@ -1,15 +0,0 @@
import { ModelPicker } from "./ModelPicker"
import { openRouterDefaultModelId } from "../../../../src/shared/api"
export const OpenRouterModelPicker = () => (
<ModelPicker
defaultModelId={openRouterDefaultModelId}
modelsKey="openRouterModels"
configKey="openRouterModelId"
infoKey="openRouterModelInfo"
refreshMessageType="refreshOpenRouterModels"
serviceName="OpenRouter"
serviceUrl="https://openrouter.ai/models"
recommendedModel="anthropic/claude-3.7-sonnet"
/>
)

View file

@ -1,22 +0,0 @@
import { ModelPicker } from "./ModelPicker"
import { requestyDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "@/context/ExtensionStateContext"
export const RequestyModelPicker = () => {
const { apiConfiguration } = useExtensionState()
return (
<ModelPicker
defaultModelId={requestyDefaultModelId}
modelsKey="requestyModels"
configKey="requestyModelId"
infoKey="requestyModelInfo"
refreshMessageType="refreshRequestyModels"
refreshValues={{
apiKey: apiConfiguration?.requestyApiKey,
}}
serviceName="Requesty"
serviceUrl="https://requesty.ai"
recommendedModel="anthropic/claude-3-7-sonnet-latest"
/>
)
}

View file

@ -1,6 +1,6 @@
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { Dropdown, type DropdownOption } from "vscrui"
import { Button, Dropdown, type DropdownOption } from "vscrui"
import {
AlertDialog,
@ -14,7 +14,6 @@ import {
} from "@/components/ui"
import { vscode } from "../../utils/vscode"
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { ExtensionStateContextType, useExtensionState } from "../../context/ExtensionStateContext"
import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments"
import { ApiConfiguration } from "../../../../src/shared/api"
@ -33,19 +32,17 @@ export interface SettingsViewRef {
const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone }, ref) => {
const extensionState = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("")
const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
const [cachedState, setCachedState] = useState(extensionState)
const [isChangeDetected, setChangeDetected] = useState(false)
const prevApiConfigName = useRef(extensionState.currentApiConfigName)
const confirmDialogHandler = useRef<() => void>()
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
// TODO: Reduce WebviewMessage/ExtensionState complexity
const { currentApiConfigName } = extensionState
const {
apiConfiguration,
alwaysAllowReadOnly,
allowedCommands,
alwaysAllowBrowser,
@ -70,17 +67,19 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
writeDelayMs,
} = cachedState
//Make sure apiConfiguration is initialized and managed by SettingsView
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
useEffect(() => {
// Update only when currentApiConfigName is changed
// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration
// Update only when currentApiConfigName is changed.
// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration.
if (prevApiConfigName.current === currentApiConfigName) {
return
}
setCachedState((prevCachedState) => ({
...prevCachedState,
...extensionState,
}))
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
prevApiConfigName.current = currentApiConfigName
// console.log("useEffect: currentApiConfigName changed, setChangeDetected -> false")
setChangeDetected(false)
}, [currentApiConfigName, extensionState, isChangeDetected])
@ -90,11 +89,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
if (prevState[field] === value) {
return prevState
}
// console.log(`setCachedStateField(${field} -> ${value}): setChangeDetected -> true`)
setChangeDetected(true)
return {
...prevState,
[field]: value,
}
return { ...prevState, [field]: value }
})
},
[],
@ -107,15 +105,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
return prevState
}
// console.log(`setApiConfigurationField(${field} -> ${value}): setChangeDetected -> true`)
setChangeDetected(true)
return {
...prevState,
apiConfiguration: {
...prevState.apiConfiguration,
[field]: value,
},
}
return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } }
})
},
[],
@ -126,7 +119,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
if (prevState.experiments?.[id] === enabled) {
return prevState
}
// console.log("setExperimentEnabled: setChangeDetected -> true")
setChangeDetected(true)
return {
...prevState,
experiments: { ...prevState.experiments, [id]: enabled },
@ -134,19 +130,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
})
}, [])
const isSettingValid = !errorMessage
const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(
apiConfiguration,
extensionState.glamaModels,
extensionState.openRouterModels,
)
setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult)
if (!apiValidationResult && !modelIdValidationResult) {
if (isSettingValid) {
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
@ -171,27 +158,11 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
vscode.postMessage({ type: "updateExperimental", values: experiments })
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
// console.log("handleSubmit: setChangeDetected -> false")
setChangeDetected(false)
}
}
useEffect(() => {
setApiErrorMessage(undefined)
setModelIdErrorMessage(undefined)
}, [apiConfiguration])
// Initial validation on mount
useEffect(() => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(
apiConfiguration,
extensionState.glamaModels,
extensionState.openRouterModels,
)
setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult)
}, [apiConfiguration, extensionState.glamaModels, extensionState.openRouterModels])
const checkUnsaveChanges = useCallback(
(then: () => void) => {
if (isChangeDetected) {
@ -204,13 +175,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
[isChangeDetected],
)
useImperativeHandle(
ref,
() => ({
checkUnsaveChanges,
}),
[checkUnsaveChanges],
)
useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges])
const onConfirmDialogResult = useCallback((confirm: boolean) => {
if (confirm) {
@ -228,10 +193,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
const newCommands = [...currentCommands, commandInput]
setCachedStateField("allowedCommands", newCommands)
setCommandInput("")
vscode.postMessage({
type: "allowedCommands",
commands: newCommands,
})
vscode.postMessage({ type: "allowedCommands", commands: newCommands })
}
}
@ -285,13 +247,14 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
justifyContent: "space-between",
gap: "6px",
}}>
<VSCodeButton
appearance="primary"
title={isChangeDetected ? "Save changes" : "Nothing changed"}
<Button
appearance={isSettingValid ? "primary" : "secondary"}
className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
title={!isSettingValid ? errorMessage : isChangeDetected ? "Save changes" : "Nothing changed"}
onClick={handleSubmit}
disabled={!isChangeDetected}>
disabled={!isChangeDetected || !isSettingValid}>
Save
</VSCodeButton>
</Button>
<VSCodeButton
appearance="secondary"
title="Discard unsaved changes and close settings panel"
@ -342,8 +305,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
uriScheme={extensionState.uriScheme}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
apiErrorMessage={apiErrorMessage}
modelIdErrorMessage={modelIdErrorMessage}
errorMessage={errorMessage}
setErrorMessage={setErrorMessage}
/>
</div>
</div>

View file

@ -0,0 +1,29 @@
import { Slider } from "@/components/ui"
import { ApiConfiguration, ModelInfo, THINKING_BUDGET } from "../../../../src/shared/api"
interface ThinkingBudgetProps {
apiConfiguration: ApiConfiguration
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
modelInfo?: ModelInfo
}
export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => {
const budget = apiConfiguration?.anthropicThinking ?? THINKING_BUDGET.default
return modelInfo && modelInfo.thinking ? (
<div className="flex flex-col gap-1 mt-2">
<div className="font-medium">Thinking Budget</div>
<div className="flex items-center gap-1">
<Slider
min={THINKING_BUDGET.min}
max={(modelInfo.maxTokens ?? THINKING_BUDGET.default) - 1}
step={THINKING_BUDGET.step}
value={[budget]}
onValueChange={(value) => setApiConfigurationField("anthropicThinking", value[0])}
/>
<div className="w-12 text-sm text-center">{budget}</div>
</div>
</div>
) : null
}

View file

@ -1,15 +0,0 @@
import { ModelPicker } from "./ModelPicker"
import { unboundDefaultModelId } from "../../../../src/shared/api"
export const UnboundModelPicker = () => (
<ModelPicker
defaultModelId={unboundDefaultModelId}
modelsKey="unboundModels"
configKey="unboundModelId"
infoKey="unboundModelInfo"
refreshMessageType="refreshUnboundModels"
serviceName="Unbound"
serviceUrl="https://api.getunbound.ai/models"
recommendedModel={unboundDefaultModelId}
/>
)

View file

@ -51,6 +51,8 @@ describe("ApiOptions", () => {
render(
<ExtensionStateContextProvider>
<ApiOptions
errorMessage={undefined}
setErrorMessage={() => {}}
uriScheme={undefined}
apiConfiguration={{}}
setApiConfigurationField={() => {}}
@ -69,4 +71,6 @@ describe("ApiOptions", () => {
renderApiOptions({ fromWelcomeView: true })
expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument()
})
//TODO: More test cases needed
})

View file

@ -3,7 +3,6 @@
import { screen, fireEvent, render } from "@testing-library/react"
import { act } from "react"
import { ModelPicker } from "../ModelPicker"
import { useExtensionState } from "../../../context/ExtensionStateContext"
jest.mock("../../../context/ExtensionStateContext", () => ({
useExtensionState: jest.fn(),
@ -20,36 +19,40 @@ global.ResizeObserver = MockResizeObserver
Element.prototype.scrollIntoView = jest.fn()
describe("ModelPicker", () => {
const mockOnUpdateApiConfig = jest.fn()
const mockSetApiConfiguration = jest.fn()
const mockSetApiConfigurationField = jest.fn()
const modelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
}
const mockModels = {
model1: { name: "Model 1", description: "Test model 1", ...modelInfo },
model2: { name: "Model 2", description: "Test model 2", ...modelInfo },
}
const defaultProps = {
apiConfiguration: {},
defaultModelId: "model1",
modelsKey: "glamaModels" as const,
configKey: "glamaModelId" as const,
infoKey: "glamaModelInfo" as const,
refreshMessageType: "refreshGlamaModels" as const,
defaultModelInfo: modelInfo,
modelIdKey: "glamaModelId" as const,
modelInfoKey: "glamaModelInfo" as const,
serviceName: "Test Service",
serviceUrl: "https://test.service",
recommendedModel: "recommended-model",
}
const mockModels = {
model1: { name: "Model 1", description: "Test model 1" },
model2: { name: "Model 2", description: "Test model 2" },
models: mockModels,
setApiConfigurationField: mockSetApiConfigurationField,
}
beforeEach(() => {
jest.clearAllMocks()
;(useExtensionState as jest.Mock).mockReturnValue({
apiConfiguration: {},
setApiConfiguration: mockSetApiConfiguration,
glamaModels: mockModels,
onUpdateApiConfig: mockOnUpdateApiConfig,
})
})
it("calls onUpdateApiConfig when a model is selected", async () => {
it("calls setApiConfigurationField when a model is selected", async () => {
await act(async () => {
render(<ModelPicker {...defaultProps} />)
})
@ -67,20 +70,12 @@ describe("ModelPicker", () => {
await act(async () => {
// Find and click the model item by its value.
const modelItem = screen.getByRole("option", { name: "model2" })
fireEvent.click(modelItem)
const modelItem = screen.getByTestId("model-input")
fireEvent.input(modelItem, { target: { value: "model2" } })
})
// Verify the API config was updated.
expect(mockSetApiConfiguration).toHaveBeenCalledWith({
glamaModelId: "model2",
glamaModelInfo: mockModels["model2"],
})
// Verify onUpdateApiConfig was called with the new config.
expect(mockOnUpdateApiConfig).toHaveBeenCalledWith({
glamaModelId: "model2",
glamaModelInfo: mockModels["model2"],
})
expect(mockSetApiConfigurationField).toHaveBeenCalledWith(defaultProps.modelIdKey, "model2")
expect(mockSetApiConfigurationField).toHaveBeenCalledWith(defaultProps.modelInfoKey, mockModels.model2)
})
})

View file

@ -4,94 +4,97 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
}
const AlertDialogPortal = AlertDialogPrimitive.Portal
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
}
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
)
}
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-left", className)} {...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"
function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
)
}
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-row justify-end space-x-2", className)} {...props} />
)
AlertDialogFooter.displayName = "AlertDialogFooter"
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-base text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "secondary" }), "mt-0", className)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
}
function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return <AlertDialogPrimitive.Cancel className={cn(buttonVariants({ variant: "outline" }), className)} {...props} />
}
export {
AlertDialog,

View file

@ -0,0 +1,522 @@
/* eslint-disable react/jsx-pascal-case */
"use client"
import * as React from "react"
import { composeEventHandlers } from "@radix-ui/primitive"
import { useComposedRefs } from "@radix-ui/react-compose-refs"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { Primitive } from "@radix-ui/react-primitive"
import * as RovingFocusGroupPrimitive from "@radix-ui/react-roving-focus"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { Command as CommandPrimitive } from "cmdk"
export type ComboboxContextProps = {
inputValue: string
onInputValueChange: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void
onInputBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void
open: boolean
onOpenChange: (open: boolean) => void
currentTabStopId: string | null
onCurrentTabStopIdChange: (currentTabStopId: string | null) => void
inputRef: React.RefObject<HTMLInputElement>
tagGroupRef: React.RefObject<React.ElementRef<typeof RovingFocusGroupPrimitive.Root>>
disabled?: boolean
required?: boolean
} & (
| Required<Pick<ComboboxSingleProps, "type" | "value" | "onValueChange">>
| Required<Pick<ComboboxMultipleProps, "type" | "value" | "onValueChange">>
)
const ComboboxContext = React.createContext<ComboboxContextProps>({
type: "single",
value: "",
onValueChange: () => {},
inputValue: "",
onInputValueChange: () => {},
onInputBlur: () => {},
open: false,
onOpenChange: () => {},
currentTabStopId: null,
onCurrentTabStopIdChange: () => {},
inputRef: { current: null },
tagGroupRef: { current: null },
disabled: false,
required: false,
})
export const useComboboxContext = () => React.useContext(ComboboxContext)
export type ComboboxType = "single" | "multiple"
export interface ComboboxBaseProps
extends React.ComponentProps<typeof PopoverPrimitive.Root>,
Omit<React.ComponentProps<typeof CommandPrimitive>, "value" | "defaultValue" | "onValueChange"> {
type?: ComboboxType | undefined
inputValue?: string
defaultInputValue?: string
onInputValueChange?: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void
onInputBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void
disabled?: boolean
required?: boolean
}
export type ComboboxValue<T extends ComboboxType = "single"> = T extends "single"
? string
: T extends "multiple"
? string[]
: never
export interface ComboboxSingleProps {
type: "single"
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
}
export interface ComboboxMultipleProps {
type: "multiple"
value?: string[]
defaultValue?: string[]
onValueChange?: (value: string[]) => void
}
export type ComboboxProps = ComboboxBaseProps & (ComboboxSingleProps | ComboboxMultipleProps)
export const Combobox = React.forwardRef(
<T extends ComboboxType = "single">(
{
type = "single" as T,
open: openProp,
onOpenChange,
defaultOpen,
modal,
children,
value: valueProp,
defaultValue,
onValueChange,
inputValue: inputValueProp,
defaultInputValue,
onInputValueChange,
onInputBlur,
disabled,
required,
...props
}: ComboboxProps,
ref: React.ForwardedRef<React.ElementRef<typeof CommandPrimitive>>,
) => {
const [value = type === "multiple" ? [] : "", setValue] = useControllableState<ComboboxValue<T>>({
prop: valueProp as ComboboxValue<T>,
defaultProp: defaultValue as ComboboxValue<T>,
onChange: onValueChange as (value: ComboboxValue<T>) => void,
})
const [inputValue = "", setInputValue] = useControllableState({
prop: inputValueProp,
defaultProp: defaultInputValue,
})
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const [currentTabStopId, setCurrentTabStopId] = React.useState<string | null>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const tagGroupRef = React.useRef<React.ElementRef<typeof RovingFocusGroupPrimitive.Root>>(null)
const handleInputValueChange: ComboboxContextProps["onInputValueChange"] = React.useCallback(
(inputValue, reason) => {
setInputValue(inputValue)
onInputValueChange?.(inputValue, reason)
},
[setInputValue, onInputValueChange],
)
return (
<ComboboxContext.Provider
value={
{
type,
value,
onValueChange: setValue,
inputValue,
onInputValueChange: handleInputValueChange,
onInputBlur,
open,
onOpenChange: setOpen,
currentTabStopId,
onCurrentTabStopIdChange: setCurrentTabStopId,
inputRef,
tagGroupRef,
disabled,
required,
} as ComboboxContextProps
}>
<PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal={modal}>
<CommandPrimitive ref={ref} {...props}>
{children}
{!open && <CommandPrimitive.List aria-hidden hidden />}
</CommandPrimitive>
</PopoverPrimitive.Root>
</ComboboxContext.Provider>
)
},
)
Combobox.displayName = "Combobox"
export const ComboboxTagGroup = React.forwardRef<
React.ElementRef<typeof RovingFocusGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RovingFocusGroupPrimitive.Root>
>((props, ref) => {
const { currentTabStopId, onCurrentTabStopIdChange, tagGroupRef, type } = useComboboxContext()
if (type !== "multiple") {
throw new Error('<ComboboxTagGroup> should only be used when type is "multiple"')
}
const composedRefs = useComposedRefs(ref, tagGroupRef)
return (
<RovingFocusGroupPrimitive.Root
ref={composedRefs}
tabIndex={-1}
currentTabStopId={currentTabStopId}
onCurrentTabStopIdChange={onCurrentTabStopIdChange}
onBlur={() => onCurrentTabStopIdChange(null)}
{...props}
/>
)
})
ComboboxTagGroup.displayName = "ComboboxTagGroup"
export interface ComboboxTagGroupItemProps
extends React.ComponentPropsWithoutRef<typeof RovingFocusGroupPrimitive.Item> {
value: string
disabled?: boolean
}
const ComboboxTagGroupItemContext = React.createContext<Pick<ComboboxTagGroupItemProps, "value" | "disabled">>({
value: "",
disabled: false,
})
const useComboboxTagGroupItemContext = () => React.useContext(ComboboxTagGroupItemContext)
export const ComboboxTagGroupItem = React.forwardRef<
React.ElementRef<typeof RovingFocusGroupPrimitive.Item>,
ComboboxTagGroupItemProps
>(({ onClick, onKeyDown, value: valueProp, disabled, ...props }, ref) => {
const { value, onValueChange, inputRef, currentTabStopId, type } = useComboboxContext()
if (type !== "multiple") {
throw new Error('<ComboboxTagGroupItem> should only be used when type is "multiple"')
}
const lastItemValue = value.at(-1)
return (
<ComboboxTagGroupItemContext.Provider value={{ value: valueProp, disabled }}>
<RovingFocusGroupPrimitive.Item
ref={ref}
onKeyDown={composeEventHandlers(onKeyDown, (event) => {
if (event.key === "Escape") {
inputRef.current?.focus()
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault()
inputRef.current?.focus()
}
if (event.key === "ArrowRight" && currentTabStopId === lastItemValue) {
inputRef.current?.focus()
}
if (event.key === "Backspace" || event.key === "Delete") {
onValueChange(value.filter((v) => v !== currentTabStopId))
inputRef.current?.focus()
}
})}
onClick={composeEventHandlers(onClick, () => disabled && inputRef.current?.focus())}
tabStopId={valueProp}
focusable={!disabled}
data-disabled={disabled}
active={valueProp === lastItemValue}
{...props}
/>
</ComboboxTagGroupItemContext.Provider>
)
})
ComboboxTagGroupItem.displayName = "ComboboxTagGroupItem"
export const ComboboxTagGroupItemRemove = React.forwardRef<
React.ElementRef<typeof Primitive.button>,
React.ComponentPropsWithoutRef<typeof Primitive.button>
>(({ onClick, ...props }, ref) => {
const { value, onValueChange, type } = useComboboxContext()
if (type !== "multiple") {
throw new Error('<ComboboxTagGroupItemRemove> should only be used when type is "multiple"')
}
const { value: valueProp, disabled } = useComboboxTagGroupItemContext()
return (
<Primitive.button
ref={ref}
aria-hidden
tabIndex={-1}
disabled={disabled}
onClick={composeEventHandlers(onClick, () => onValueChange(value.filter((v) => v !== valueProp)))}
{...props}
/>
)
})
ComboboxTagGroupItemRemove.displayName = "ComboboxTagGroupItemRemove"
export const ComboboxInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
Omit<React.ComponentProps<typeof CommandPrimitive.Input>, "value" | "onValueChange">
>(({ onKeyDown, onMouseDown, onFocus, onBlur, ...props }, ref) => {
const {
type,
inputValue,
onInputValueChange,
onInputBlur,
open,
onOpenChange,
value,
onValueChange,
inputRef,
disabled,
required,
tagGroupRef,
} = useComboboxContext()
const composedRefs = useComposedRefs(ref, inputRef)
return (
<CommandPrimitive.Input
ref={composedRefs}
disabled={disabled}
required={required}
value={inputValue}
onValueChange={(search) => {
if (!open) {
onOpenChange(true)
}
// Schedule input value change to the next tick.
setTimeout(() => onInputValueChange(search, "inputChange"))
if (!search && type === "single") {
onValueChange("")
}
}}
onKeyDown={composeEventHandlers(onKeyDown, (event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (!open) {
event.preventDefault()
onOpenChange(true)
}
}
if (type !== "multiple") {
return
}
if (event.key === "ArrowLeft" && !inputValue && value.length) {
tagGroupRef.current?.focus()
}
if (event.key === "Backspace" && !inputValue) {
onValueChange(value.slice(0, -1))
}
})}
onMouseDown={composeEventHandlers(onMouseDown, () => onOpenChange(!!inputValue || !open))}
onFocus={composeEventHandlers(onFocus, () => onOpenChange(true))}
onBlur={composeEventHandlers(onBlur, (event) => {
if (!event.relatedTarget?.hasAttribute("cmdk-list")) {
onInputBlur?.(event)
}
})}
{...props}
/>
)
})
ComboboxInput.displayName = "ComboboxInput"
export const ComboboxClear = React.forwardRef<
React.ElementRef<typeof Primitive.button>,
React.ComponentPropsWithoutRef<typeof Primitive.button>
>(({ onClick, ...props }, ref) => {
const { value, onValueChange, inputValue, onInputValueChange, type } = useComboboxContext()
const isValueEmpty = type === "single" ? !value : !value.length
return (
<Primitive.button
ref={ref}
disabled={isValueEmpty && !inputValue}
onClick={composeEventHandlers(onClick, () => {
if (type === "single") {
onValueChange("")
} else {
onValueChange([])
}
onInputValueChange("", "clearClick")
})}
{...props}
/>
)
})
ComboboxClear.displayName = "ComboboxClear"
export const ComboboxTrigger = PopoverPrimitive.Trigger
export const ComboboxAnchor = PopoverPrimitive.Anchor
export const ComboboxPortal = PopoverPrimitive.Portal
export const ComboboxContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ children, onOpenAutoFocus, onInteractOutside, ...props }, ref) => (
<PopoverPrimitive.Content
asChild
ref={ref}
onOpenAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => event.preventDefault())}
onCloseAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => event.preventDefault())}
onInteractOutside={composeEventHandlers(onInteractOutside, (event) => {
if (event.target instanceof Element && event.target.hasAttribute("cmdk-input")) {
event.preventDefault()
}
})}
{...props}>
<CommandPrimitive.List>{children}</CommandPrimitive.List>
</PopoverPrimitive.Content>
))
ComboboxContent.displayName = "ComboboxContent"
export const ComboboxEmpty = CommandPrimitive.Empty
export const ComboboxLoading = CommandPrimitive.Loading
export interface ComboboxItemProps extends Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>, "value"> {
value: string
}
const ComboboxItemContext = React.createContext({ isSelected: false })
const useComboboxItemContext = () => React.useContext(ComboboxItemContext)
const findComboboxItemText = (children: React.ReactNode) => {
let text = ""
React.Children.forEach(children, (child) => {
if (text) {
return
}
if (React.isValidElement<{ children: React.ReactNode }>(child)) {
if (child.type === ComboboxItemText) {
text = child.props.children as string
} else {
text = findComboboxItemText(child.props.children)
}
}
})
return text
}
export const ComboboxItem = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, ComboboxItemProps>(
({ value: valueProp, children, onMouseDown, ...props }, ref) => {
const { type, value, onValueChange, onInputValueChange, onOpenChange } = useComboboxContext()
const inputValue = React.useMemo(() => findComboboxItemText(children), [children])
const isSelected = type === "single" ? value === valueProp : value.includes(valueProp)
return (
<ComboboxItemContext.Provider value={{ isSelected }}>
<CommandPrimitive.Item
ref={ref}
onMouseDown={composeEventHandlers(onMouseDown, (event) => event.preventDefault())}
onSelect={() => {
if (type === "multiple") {
onValueChange(
value.includes(valueProp)
? value.filter((v) => v !== valueProp)
: [...value, valueProp],
)
onInputValueChange("", "itemSelect")
} else {
onValueChange(valueProp)
onInputValueChange(inputValue, "itemSelect")
// Schedule open change to the next tick.
setTimeout(() => onOpenChange(false))
}
}}
value={inputValue}
{...props}>
{children}
</CommandPrimitive.Item>
</ComboboxItemContext.Provider>
)
},
)
ComboboxItem.displayName = "ComboboxItem"
export const ComboboxItemIndicator = React.forwardRef<
React.ElementRef<typeof Primitive.span>,
React.ComponentPropsWithoutRef<typeof Primitive.span>
>((props, ref) => {
const { isSelected } = useComboboxItemContext()
if (!isSelected) {
return null
}
return <Primitive.span ref={ref} aria-hidden {...props} />
})
ComboboxItemIndicator.displayName = "ComboboxItemIndicator"
export interface ComboboxItemTextProps extends React.ComponentPropsWithoutRef<typeof React.Fragment> {
children: string
}
export const ComboboxItemText = (props: ComboboxItemTextProps) => <React.Fragment {...props} />
ComboboxItemText.displayName = "ComboboxItemText"
export const ComboboxGroup = CommandPrimitive.Group
export const ComboboxSeparator = CommandPrimitive.Separator
const Root = Combobox
const TagGroup = ComboboxTagGroup
const TagGroupItem = ComboboxTagGroupItem
const TagGroupItemRemove = ComboboxTagGroupItemRemove
const Input = ComboboxInput
const Clear = ComboboxClear
const Trigger = ComboboxTrigger
const Anchor = ComboboxAnchor
const Portal = ComboboxPortal
const Content = ComboboxContent
const Empty = ComboboxEmpty
const Loading = ComboboxLoading
const Item = ComboboxItem
const ItemIndicator = ComboboxItemIndicator
const ItemText = ComboboxItemText
const Group = ComboboxGroup
const Separator = ComboboxSeparator
export {
Root,
TagGroup,
TagGroupItem,
TagGroupItemRemove,
Input,
Clear,
Trigger,
Anchor,
Portal,
Content,
Empty,
Loading,
Item,
ItemIndicator,
ItemText,
Group,
Separator,
}

View file

@ -0,0 +1,177 @@
"use client"
import * as React from "react"
import { Slottable } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority"
import { Check, ChevronsUpDown, Loader, X } from "lucide-react"
import { cn } from "@/lib/utils"
import * as ComboboxPrimitive from "@/components/ui/combobox-primitive"
import { badgeVariants } from "@/components/ui/badge"
// import * as ComboboxPrimitive from "@/registry/default/ui/combobox-primitive"
import {
InputBase,
InputBaseAdornmentButton,
InputBaseControl,
InputBaseFlexWrapper,
InputBaseInput,
} from "@/components/ui/input-base"
export const Combobox = ComboboxPrimitive.Root
const ComboboxInputBase = React.forwardRef<
React.ElementRef<typeof InputBase>,
React.ComponentPropsWithoutRef<typeof InputBase>
>(({ children, ...props }, ref) => (
<ComboboxPrimitive.Anchor asChild>
<InputBase ref={ref} {...props}>
{children}
<ComboboxPrimitive.Clear asChild>
<InputBaseAdornmentButton>
<X />
</InputBaseAdornmentButton>
</ComboboxPrimitive.Clear>
<ComboboxPrimitive.Trigger asChild>
<InputBaseAdornmentButton>
<ChevronsUpDown />
</InputBaseAdornmentButton>
</ComboboxPrimitive.Trigger>
</InputBase>
</ComboboxPrimitive.Anchor>
))
ComboboxInputBase.displayName = "ComboboxInputBase"
export const ComboboxInput = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Input>
>((props, ref) => (
<ComboboxInputBase>
<InputBaseControl>
<ComboboxPrimitive.Input asChild>
<InputBaseInput ref={ref} {...props} />
</ComboboxPrimitive.Input>
</InputBaseControl>
</ComboboxInputBase>
))
ComboboxInput.displayName = "ComboboxInput"
export const ComboboxTagsInput = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Input>
>(({ children, ...props }, ref) => (
<ComboboxInputBase>
<ComboboxPrimitive.ComboboxTagGroup asChild>
<InputBaseFlexWrapper className="flex items-center gap-2">
{children}
<InputBaseControl>
<ComboboxPrimitive.Input asChild>
<InputBaseInput ref={ref} {...props} />
</ComboboxPrimitive.Input>
</InputBaseControl>
</InputBaseFlexWrapper>
</ComboboxPrimitive.ComboboxTagGroup>
</ComboboxInputBase>
))
ComboboxTagsInput.displayName = "ComboboxTagsInput"
export const ComboboxTag = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.ComboboxTagGroupItem>,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.ComboboxTagGroupItem>
>(({ children, className, ...props }, ref) => (
<ComboboxPrimitive.ComboboxTagGroupItem
ref={ref}
className={cn(
badgeVariants({ variant: "outline" }),
"group gap-1 pr-1.5 data-[disabled]:opacity-50",
className,
)}
{...props}>
<Slottable>{children}</Slottable>
<ComboboxPrimitive.ComboboxTagGroupItemRemove className="group-data-[disabled]:pointer-events-none">
<X className="size-4" />
<span className="sr-only">Remove</span>
</ComboboxPrimitive.ComboboxTagGroupItemRemove>
</ComboboxPrimitive.ComboboxTagGroupItem>
))
ComboboxTag.displayName = "ComboboxTag"
export const ComboboxContent = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Content>
>(({ className, align = "start", alignOffset = 0, ...props }, ref) => (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
className={cn(
"min-w-72 border-vscode-dropdown-border relative z-50 left-0 max-h-96 w-[--radix-popover-trigger-width] overflow-y-auto overflow-x-hidden rounded-xs border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ComboboxPrimitive.Portal>
))
ComboboxContent.displayName = "ComboboxContent"
export const ComboboxEmpty = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Empty>
>(({ className, ...props }, ref) => (
<ComboboxPrimitive.Empty ref={ref} className={cn("py-6 text-center text-sm", className)} {...props} />
))
ComboboxEmpty.displayName = "ComboboxEmpty"
export const ComboboxLoading = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.Loading>,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Loading>
>(({ className, ...props }, ref) => (
<ComboboxPrimitive.Loading
ref={ref}
className={cn("flex items-center justify-center px-1.5 py-2", className)}
{...props}>
<Loader className="size-4 animate-spin [mask:conic-gradient(transparent_45deg,_white)]" />
</ComboboxPrimitive.Loading>
))
ComboboxLoading.displayName = "ComboboxLoading"
export const ComboboxGroup = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Group>
>(({ className, ...props }, ref) => (
<ComboboxPrimitive.Group
ref={ref}
className={cn(
"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold",
className,
)}
{...props}
/>
))
ComboboxGroup.displayName = "ComboboxGroup"
const ComboboxSeparator = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ComboboxPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
))
ComboboxSeparator.displayName = "ComboboxSeparator"
export const comboboxItemStyle = cva(
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-vscode-dropdown-foreground data-[disabled=true]:opacity-50",
)
export const ComboboxItem = React.forwardRef<
React.ElementRef<typeof ComboboxPrimitive.Item>,
Omit<React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Item>, "children"> &
Pick<React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.ItemText>, "children">
>(({ className, children, ...props }, ref) => (
<ComboboxPrimitive.Item ref={ref} className={cn(comboboxItemStyle(), className)} {...props}>
<ComboboxPrimitive.ItemText>{children}</ComboboxPrimitive.ItemText>
<ComboboxPrimitive.ItemIndicator className="absolute right-2 flex size-3.5 items-center justify-center">
<Check className="size-4" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
))
ComboboxItem.displayName = "ComboboxItem"

View file

@ -1,96 +1,108 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
const DialogTrigger = DialogPrimitive.Trigger
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
const DialogPortal = DialogPrimitive.Portal
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
const DialogClose = DialogPrimitive.Close
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}>
{children}
<DialogPrimitive.Close className="cursor-pointer absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
{...props}
/>
)
}
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,157 @@
/* eslint-disable react/jsx-no-comment-textnodes */
/* eslint-disable react/jsx-pascal-case */
"use client"
import * as React from "react"
import { composeEventHandlers } from "@radix-ui/primitive"
import { composeRefs } from "@radix-ui/react-compose-refs"
import { Primitive } from "@radix-ui/react-primitive"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/lib/utils"
import { Button } from "./button"
export type InputBaseContextProps = Pick<InputBaseProps, "autoFocus" | "disabled"> & {
controlRef: React.RefObject<HTMLElement>
onFocusedChange: (focused: boolean) => void
}
const InputBaseContext = React.createContext<InputBaseContextProps>({
autoFocus: false,
controlRef: { current: null },
disabled: false,
onFocusedChange: () => {},
})
const useInputBaseContext = () => React.useContext(InputBaseContext)
export interface InputBaseProps extends React.ComponentPropsWithoutRef<typeof Primitive.div> {
autoFocus?: boolean
disabled?: boolean
}
export const InputBase = React.forwardRef<React.ElementRef<typeof Primitive.div>, InputBaseProps>(
({ autoFocus, disabled, className, onClick, ...props }, ref) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [focused, setFocused] = React.useState(false)
const controlRef = React.useRef<HTMLElement>(null)
return (
<InputBaseContext.Provider
value={{
autoFocus,
controlRef,
disabled,
onFocusedChange: setFocused,
}}>
<Primitive.div
ref={ref}
onClick={composeEventHandlers(onClick, (event) => {
// Based on MUI's <InputBase /> implementation.
// https://github.com/mui/material-ui/blob/master/packages/mui-material/src/InputBase/InputBase.js#L458~L460
if (controlRef.current && event.currentTarget === event.target) {
controlRef.current.focus()
}
})}
className={cn(
"flex w-full text-vscode-input-foreground border border-vscode-dropdown-border bg-vscode-input-background rounded-xs px-3 py-0.5 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50",
disabled && "cursor-not-allowed opacity-50",
className,
)}
{...props}
/>
</InputBaseContext.Provider>
)
},
)
InputBase.displayName = "InputBase"
export const InputBaseFlexWrapper = React.forwardRef<
React.ElementRef<typeof Primitive.div>,
React.ComponentPropsWithoutRef<typeof Primitive.div>
>(({ className, ...props }, ref) => (
<Primitive.div ref={ref} className={cn("flex flex-1 flex-wrap", className)} {...props} />
))
InputBaseFlexWrapper.displayName = "InputBaseFlexWrapper"
export const InputBaseControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ onFocus, onBlur, ...props }, ref) => {
const { controlRef, autoFocus, disabled, onFocusedChange } = useInputBaseContext()
return (
<Slot
ref={composeRefs(controlRef, ref)}
autoFocus={autoFocus}
onFocus={composeEventHandlers(onFocus, () => onFocusedChange(true))}
onBlur={composeEventHandlers(onBlur, () => onFocusedChange(false))}
{...{ disabled }}
{...props}
/>
)
})
InputBaseControl.displayName = "InputBaseControl"
export interface InputBaseAdornmentProps extends React.ComponentPropsWithoutRef<"div"> {
asChild?: boolean
disablePointerEvents?: boolean
}
export const InputBaseAdornment = React.forwardRef<React.ElementRef<"div">, InputBaseAdornmentProps>(
({ className, disablePointerEvents, asChild, children, ...props }, ref) => {
const Comp = asChild ? Slot : typeof children === "string" ? "p" : "div"
const isAction = React.isValidElement(children) && children.type === InputBaseAdornmentButton
return (
<Comp
ref={ref}
className={cn(
"flex items-center text-muted-foreground [&_svg]:size-4",
(!isAction || disablePointerEvents) && "pointer-events-none",
className,
)}
{...props}>
{children}
</Comp>
)
},
)
InputBaseAdornment.displayName = "InputBaseAdornment"
export const InputBaseAdornmentButton = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentPropsWithoutRef<typeof Button>
>(({ type = "button", variant = "ghost", size = "icon", disabled: disabledProp, className, ...props }, ref) => {
const { disabled } = useInputBaseContext()
return (
<Button
ref={ref}
type={type}
variant={variant}
size={size}
disabled={disabled || disabledProp}
className={cn("size-6", className)}
{...props}
/>
)
})
InputBaseAdornmentButton.displayName = "InputBaseAdornmentButton"
export const InputBaseInput = React.forwardRef<
React.ElementRef<typeof Primitive.input>,
React.ComponentPropsWithoutRef<typeof Primitive.input>
>(({ className, ...props }, ref) => (
<Primitive.input
ref={ref}
className={cn(
"w-full flex-1 bg-transparent file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none disabled:pointer-events-none",
className,
)}
{...props}
/>
))
InputBaseInput.displayName = "InputBaseInput"

View file

@ -11,8 +11,8 @@ const Slider = React.forwardRef<
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
<SliderPrimitive.Track className="relative w-full h-[8px] grow overflow-hidden bg-vscode-button-secondaryBackground border border-[#767676] dark:border-[#858585] rounded-sm">
<SliderPrimitive.Range className="absolute h-full bg-vscode-button-background" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full border border-primary/50 bg-primary shadow transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>

View file

@ -12,16 +12,14 @@ const WelcomeView = () => {
const handleSubmit = useCallback(() => {
const error = validateApiConfiguration(apiConfiguration)
if (error) {
setErrorMessage(error)
return
}
setErrorMessage(undefined)
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration,
})
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
}, [apiConfiguration, currentApiConfigName])
return (
@ -42,6 +40,8 @@ const WelcomeView = () => {
apiConfiguration={apiConfiguration || {}}
uriScheme={uriScheme}
setApiConfigurationField={(field, value) => setApiConfiguration({ [field]: value })}
errorMessage={errorMessage}
setErrorMessage={setErrorMessage}
/>
</div>

View file

@ -1,18 +1,7 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
import { useEvent } from "react-use"
import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
import {
ApiConfiguration,
ModelInfo,
glamaDefaultModelId,
glamaDefaultModelInfo,
openRouterDefaultModelId,
openRouterDefaultModelInfo,
unboundDefaultModelId,
unboundDefaultModelInfo,
requestyDefaultModelId,
requestyDefaultModelInfo,
} from "../../../src/shared/api"
import { ApiConfiguration } from "../../../src/shared/api"
import { vscode } from "../utils/vscode"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { findLastIndex } from "../../../src/shared/array"
@ -26,11 +15,6 @@ export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
showWelcome: boolean
theme: any
glamaModels: Record<string, ModelInfo>
requestyModels: Record<string, ModelInfo>
openRouterModels: Record<string, ModelInfo>
unboundModels: Record<string, ModelInfo>
openAiModels: string[]
mcpServers: McpServer[]
currentCheckpoint?: string
filePaths: string[]
@ -70,7 +54,6 @@ export interface ExtensionStateContextType extends ExtensionState {
setRateLimitSeconds: (value: number) => void
setCurrentApiConfigName: (value: string) => void
setListApiConfigMeta: (value: ApiConfigMeta[]) => void
onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
mode: Mode
setMode: (value: Mode) => void
setCustomModePrompts: (value: CustomModePrompts) => void
@ -118,27 +101,15 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
autoApprovalEnabled: false,
customModes: [],
maxOpenTabsContext: 20,
cwd: "",
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
const [theme, setTheme] = useState<any>(undefined)
const [filePaths, setFilePaths] = useState<string[]>([])
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
[glamaDefaultModelId]: glamaDefaultModelInfo,
})
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
[unboundDefaultModelId]: unboundDefaultModelInfo,
})
const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
[requestyDefaultModelId]: requestyDefaultModelInfo,
})
const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
@ -146,18 +117,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
(value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
[],
)
const onUpdateApiConfig = useCallback((apiConfig: ApiConfiguration) => {
setState((currentState) => {
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentState.currentApiConfigName,
apiConfiguration: { ...currentState.apiConfiguration, ...apiConfig },
})
return currentState // No state update needed
})
}, [])
const handleMessage = useCallback(
(event: MessageEvent) => {
const message: ExtensionMessage = event.data
@ -202,40 +161,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
})
break
}
case "glamaModels": {
const updatedModels = message.glamaModels ?? {}
setGlamaModels({
[glamaDefaultModelId]: glamaDefaultModelInfo, // in case the extension sent a model list without the default model
...updatedModels,
})
break
}
case "openRouterModels": {
const updatedModels = message.openRouterModels ?? {}
setOpenRouterModels({
[openRouterDefaultModelId]: openRouterDefaultModelInfo, // in case the extension sent a model list without the default model
...updatedModels,
})
break
}
case "openAiModels": {
const updatedModels = message.openAiModels ?? []
setOpenAiModels(updatedModels)
break
}
case "unboundModels": {
const updatedModels = message.unboundModels ?? {}
setUnboundModels(updatedModels)
break
}
case "requestyModels": {
const updatedModels = message.requestyModels ?? {}
setRequestyModels({
[requestyDefaultModelId]: requestyDefaultModelInfo, // in case the extension sent a model list without the default model
...updatedModels,
})
break
}
case "mcpServers": {
setMcpServers(message.mcpServers ?? [])
break
@ -264,11 +189,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
didHydrateState,
showWelcome,
theme,
glamaModels,
requestyModels,
openRouterModels,
openAiModels,
unboundModels,
mcpServers,
currentCheckpoint,
filePaths,
@ -316,7 +236,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setRateLimitSeconds: (value) => setState((prevState) => ({ ...prevState, rateLimitSeconds: value })),
setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })),
setListApiConfigMeta,
onUpdateApiConfig,
setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
setCustomModePrompts: (value) => setState((prevState) => ({ ...prevState, customModePrompts: value })),
setCustomSupportPrompts: (value) => setState((prevState) => ({ ...prevState, customSupportPrompts: value })),

View file

@ -0,0 +1,45 @@
import { convertToMentionPath } from "../path-mentions"
describe("path-mentions", () => {
describe("convertToMentionPath", () => {
it("should convert an absolute path to a mention path when it starts with cwd", () => {
// Windows-style paths
expect(convertToMentionPath("C:\\Users\\user\\project\\file.txt", "C:\\Users\\user\\project")).toBe(
"@/file.txt",
)
// Unix-style paths
expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project")).toBe("@/file.txt")
})
it("should handle paths with trailing slashes in cwd", () => {
expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project/")).toBe("@/file.txt")
})
it("should be case-insensitive when matching paths", () => {
expect(convertToMentionPath("/Users/User/Project/file.txt", "/users/user/project")).toBe("@/file.txt")
})
it("should return the original path when cwd is not provided", () => {
expect(convertToMentionPath("/Users/user/project/file.txt")).toBe("/Users/user/project/file.txt")
})
it("should return the original path when it does not start with cwd", () => {
expect(convertToMentionPath("/Users/other/project/file.txt", "/Users/user/project")).toBe(
"/Users/other/project/file.txt",
)
})
it("should normalize backslashes to forward slashes", () => {
expect(convertToMentionPath("C:\\Users\\user\\project\\subdir\\file.txt", "C:\\Users\\user\\project")).toBe(
"@/subdir/file.txt",
)
})
it("should handle nested paths correctly", () => {
expect(convertToMentionPath("/Users/user/project/nested/deeply/file.txt", "/Users/user/project")).toBe(
"@/nested/deeply/file.txt",
)
})
})
})

View file

@ -0,0 +1,38 @@
/**
* Utilities for handling path-related operations in mentions
*/
/**
* Converts an absolute path to a mention-friendly path
* If the provided path starts with the current working directory,
* it's converted to a relative path prefixed with @
*
* @param path The path to convert
* @param cwd The current working directory
* @returns A mention-friendly path
*/
export function convertToMentionPath(path: string, cwd?: string): string {
const normalizedPath = path.replace(/\\/g, "/")
let normalizedCwd = cwd ? cwd.replace(/\\/g, "/") : ""
if (!normalizedCwd) {
return path
}
// Remove trailing slash from cwd if it exists
if (normalizedCwd.endsWith("/")) {
normalizedCwd = normalizedCwd.slice(0, -1)
}
// Always use case-insensitive comparison for path matching
const lowerPath = normalizedPath.toLowerCase()
const lowerCwd = normalizedCwd.toLowerCase()
if (lowerPath.startsWith(lowerCwd)) {
const relativePath = normalizedPath.substring(normalizedCwd.length)
// Ensure there's a slash after the @ symbol when we create the mention path
return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
}
return path
}

View file

@ -1,79 +1,83 @@
import {
ApiConfiguration,
glamaDefaultModelId,
openRouterDefaultModelId,
unboundDefaultModelId,
} from "../../../src/shared/api"
import { ModelInfo } from "../../../src/shared/api"
import { ApiConfiguration, ModelInfo } from "../../../src/shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) {
switch (apiConfiguration.apiProvider) {
case "anthropic":
if (!apiConfiguration.apiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "glama":
if (!apiConfiguration.glamaApiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "bedrock":
if (!apiConfiguration.awsRegion) {
return "You must choose a region to use with AWS Bedrock."
}
break
case "openrouter":
if (!apiConfiguration.openRouterApiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "vertex":
if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) {
return "You must provide a valid Google Cloud Project ID and Region."
}
break
case "gemini":
if (!apiConfiguration.geminiApiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "openai-native":
if (!apiConfiguration.openAiNativeApiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "mistral":
if (!apiConfiguration.mistralApiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "openai":
if (
!apiConfiguration.openAiBaseUrl ||
!apiConfiguration.openAiApiKey ||
!apiConfiguration.openAiModelId
) {
return "You must provide a valid base URL, API key, and model ID."
}
break
case "ollama":
if (!apiConfiguration.ollamaModelId) {
return "You must provide a valid model ID."
}
break
case "lmstudio":
if (!apiConfiguration.lmStudioModelId) {
return "You must provide a valid model ID."
}
break
case "vscode-lm":
if (!apiConfiguration.vsCodeLmModelSelector) {
return "You must provide a valid model selector."
}
break
}
if (!apiConfiguration) {
return undefined
}
switch (apiConfiguration.apiProvider) {
case "openrouter":
if (!apiConfiguration.openRouterApiKey) {
return "You must provide a valid API key."
}
break
case "glama":
if (!apiConfiguration.glamaApiKey) {
return "You must provide a valid API key."
}
break
case "unbound":
if (!apiConfiguration.unboundApiKey) {
return "You must provide a valid API key."
}
break
case "requesty":
if (!apiConfiguration.requestyApiKey) {
return "You must provide a valid API key."
}
break
case "anthropic":
if (!apiConfiguration.apiKey) {
return "You must provide a valid API key."
}
break
case "bedrock":
if (!apiConfiguration.awsRegion) {
return "You must choose a region to use with AWS Bedrock."
}
break
case "vertex":
if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) {
return "You must provide a valid Google Cloud Project ID and Region."
}
break
case "gemini":
if (!apiConfiguration.geminiApiKey) {
return "You must provide a valid API key."
}
break
case "openai-native":
if (!apiConfiguration.openAiNativeApiKey) {
return "You must provide a valid API key."
}
break
case "mistral":
if (!apiConfiguration.mistralApiKey) {
return "You must provide a valid API key."
}
break
case "openai":
if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) {
return "You must provide a valid base URL, API key, and model ID."
}
break
case "ollama":
if (!apiConfiguration.ollamaModelId) {
return "You must provide a valid model ID."
}
break
case "lmstudio":
if (!apiConfiguration.lmStudioModelId) {
return "You must provide a valid model ID."
}
break
case "vscode-lm":
if (!apiConfiguration.vsCodeLmModelSelector) {
return "You must provide a valid model selector."
}
break
}
return undefined
}
@ -82,40 +86,81 @@ export function validateModelId(
glamaModels?: Record<string, ModelInfo>,
openRouterModels?: Record<string, ModelInfo>,
unboundModels?: Record<string, ModelInfo>,
requestyModels?: Record<string, ModelInfo>,
): string | undefined {
if (apiConfiguration) {
switch (apiConfiguration.apiProvider) {
case "glama":
const glamaModelId = apiConfiguration.glamaModelId || glamaDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!glamaModelId) {
return "You must provide a model ID."
}
if (glamaModels && !Object.keys(glamaModels).includes(glamaModelId)) {
// 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
case "openrouter":
const modelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!modelId) {
return "You must provide a model ID."
}
if (openRouterModels && !Object.keys(openRouterModels).includes(modelId)) {
// 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
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
}
if (!apiConfiguration) {
return undefined
}
switch (apiConfiguration.apiProvider) {
case "openrouter":
const modelId = apiConfiguration.openRouterModelId
if (!modelId) {
return "You must provide a model ID."
}
if (
openRouterModels &&
Object.keys(openRouterModels).length > 1 &&
!Object.keys(openRouterModels).includes(modelId)
) {
return `The model ID (${modelId}) you provided is not available. Please choose a different model.`
}
break
case "glama":
const glamaModelId = apiConfiguration.glamaModelId
if (!glamaModelId) {
return "You must provide a model ID."
}
if (
glamaModels &&
Object.keys(glamaModels).length > 1 &&
!Object.keys(glamaModels).includes(glamaModelId)
) {
return `The model ID (${glamaModelId}) you provided is not available. Please choose a different model.`
}
break
case "unbound":
const unboundModelId = apiConfiguration.unboundModelId
if (!unboundModelId) {
return "You must provide a model ID."
}
if (
unboundModels &&
Object.keys(unboundModels).length > 1 &&
!Object.keys(unboundModels).includes(unboundModelId)
) {
return `The model ID (${unboundModelId}) you provided is not available. Please choose a different model.`
}
break
case "requesty":
const requestyModelId = apiConfiguration.requestyModelId
if (!requestyModelId) {
return "You must provide a model ID."
}
if (
requestyModels &&
Object.keys(requestyModels).length > 1 &&
!Object.keys(requestyModels).includes(requestyModelId)
) {
return `The model ID (${requestyModelId}) you provided is not available. Please choose a different model.`
}
break
}
return undefined
}