diff --git a/CHANGELOG.md b/CHANGELOG.md index 381e0907eb..02a4a30cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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!) diff --git a/package-lock.json b/package-lock.json index ba152e81f3..a6c75bd69b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 651de2b764..40bb6a545d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 2d1f07f833..ad58a1cf6b 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -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 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, diff --git a/src/api/providers/glama.ts b/src/api/providers/glama.ts index 72b41e5f58..946d28d24f 100644 --- a/src/api/providers/glama.ts +++ b/src/api/providers/glama.ts @@ -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 = {} + + 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 +} diff --git a/src/api/providers/lmstudio.ts b/src/api/providers/lmstudio.ts index 7efa037f46..beb3bd1b79 100644 --- a/src/api/providers/lmstudio.ts +++ b/src/api/providers/lmstudio.ts @@ -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(modelsArray)] + } catch (error) { + return [] + } +} diff --git a/src/api/providers/ollama.ts b/src/api/providers/ollama.ts index afb6117b54..de7df5d261 100644 --- a/src/api/providers/ollama.ts +++ b/src/api/providers/ollama.ts @@ -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(modelsArray)] + } catch (error) { + return [] + } +} diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index cea500df26..f1c404d50a 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -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 = {} + + 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(modelsArray)] + } catch (error) { + return [] + } +} diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index eb9e819d77..0a9488e816 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -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) { - // 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 { @@ -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 = {} + + 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 +} diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 67f43aabc5..5e570ca2a2 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -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 = {} + + 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 +} diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 0599ffa443..5e3ad8843b 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -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 = {} + + try { + const response = await axios.get("https://api.getunbound.ai/models") + + if (response.data) { + const rawModels: Record = 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 +} diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index e2bf8609ae..28e24231a2 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -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 [] + } +} diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 70410a14f1..cdd1f8f4c5 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -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" diff --git a/src/core/config/__tests__/CustomModesManager.test.ts b/src/core/config/__tests__/CustomModesManager.test.ts index 3c8236e920..4031bff906 100644 --- a/src/core/config/__tests__/CustomModesManager.test.ts +++ b/src/core/config/__tests__/CustomModesManager.test.ts @@ -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({ diff --git a/src/core/sliding-window/__tests__/sliding-window.test.ts b/src/core/sliding-window/__tests__/sliding-window.test.ts index 182dea67f5..3dcf9e5fd2 100644 --- a/src/core/sliding-window/__tests__/sliding-window.test.ts +++ b/src/core/sliding-window/__tests__/sliding-window.test.ts @@ -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), + ) }) }) diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index d213f069f1..a0fff05ea5 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -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) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c5cfc3f9c7..4fa9595ba6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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(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 | 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(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 = {} - 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(modelsArray)] - return models - } catch (error) { - return [] - } - } - - // Requesty - async readRequestyModels(): Promise | 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 = {} - try { - const config: Record = {} - 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 { - 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 | 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 | undefined> { - return this.readModelsFromCache(GlobalFileNames.glamaModels) - } - - async refreshGlamaModels() { - const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels) - - const models: Record = {} - 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 | undefined> { - return this.readModelsFromCache(GlobalFileNames.openRouterModels) - } - - async refreshOpenRouterModels() { - const openRouterModelsFilePath = path.join( - await this.ensureCacheDirectoryExists(), - GlobalFileNames.openRouterModels, - ) - - const models: Record = {} - - 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 | undefined> { - return this.readModelsFromCache(GlobalFileNames.unboundModels) - } - - async refreshUnboundModels() { - const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels) - - const models: Record = {} - try { - const response = await axios.get("https://api.getunbound.ai/models") - - if (response.data) { - const rawModels: Record = 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("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) { diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 6e3f39fa7d..6c906c7cf8 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -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, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index fe9fa39427..e87edffed1 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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 - requestyModels?: Record openRouterModels?: Record - openAiModels?: string[] + glamaModels?: Record unboundModels?: Record + requestyModels?: Record + openAiModels?: string[] mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ApiConfigMeta[] @@ -129,6 +128,7 @@ export interface ExtensionState { customModes: ModeConfig[] toolRequirements?: Record // 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 { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 106e6d243b..fde7442cc1 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -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" diff --git a/src/shared/api.ts b/src/shared/api.ts index cea760c776..5d4b8b120d 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -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, diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts new file mode 100644 index 0000000000..6088e95d99 --- /dev/null +++ b/src/shared/globalFileNames.ts @@ -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", +} diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts new file mode 100644 index 0000000000..7b6b4f8274 --- /dev/null +++ b/src/shared/globalState.ts @@ -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" diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts index 1d20e86c69..8c8a8cc672 100644 --- a/src/utils/__tests__/path.test.ts +++ b/src/utils/__tests__/path.test.ts @@ -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") }) }) diff --git a/src/utils/cost.ts b/src/utils/cost.ts index f8f5f2b125..adc2ded0a8 100644 --- a/src/utils/cost.ts +++ b/src/utils/cost.ts @@ -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) diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 1d64f934dc..22564d01a6 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -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" }, diff --git a/webview-ui/src/__mocks__/lucide-react.ts b/webview-ui/src/__mocks__/lucide-react.ts new file mode 100644 index 0000000000..d85cd25d6a --- /dev/null +++ b/webview-ui/src/__mocks__/lucide-react.ts @@ -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") diff --git a/webview-ui/src/__mocks__/vscrui.ts b/webview-ui/src/__mocks__/vscrui.ts index 76760ba5cc..9b4a20f4d6 100644 --- a/webview-ui/src/__mocks__/vscrui.ts +++ b/webview-ui/src/__mocks__/vscrui.ts @@ -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 diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index d317efb96e..dc78a3fdb3 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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( }, ref, ) => { - const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState() + const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes, cwd } = useExtensionState() const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) @@ -589,18 +590,24 @@ const ChatTextArea = forwardRef( 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((resolve) => { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index b9fc215a1c..98369cf095 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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( diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 1ff5431085..ad4ef4a8ee 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -354,7 +354,7 @@ const TaskActions = ({ item }: { item: HistoryItem | undefined }) => ( - {item?.size && ( + {!!item?.size && item.size > 0 && ( - - - - - - No model found. - - {modelIds.map((model) => ( - - {model} - - - ))} - - {allowCustomModel && ( - - { - setIsCustomModel(true) - setOpen(false) - }}> - + Add custom model - - - )} - - - - - {selectedModelId && selectedModelInfo && ( + + + + No model found. + {modelIds.map((model) => ( + + {model} + + ))} + + + + {selectedModelId && selectedModelInfo && selectedModelId === inputValue && ( If you're unsure which model to choose, Roo Code works best with{" "} - onSelect(recommendedModel)}>{recommendedModel}. + onSelect(defaultModelId)}>{defaultModelId}. You can also try searching "free" for no-cost options currently available.

- {allowCustomModel && isCustomModel && ( -
-
-

Add Custom Model

- setCustomModelId(e.target.value)} - /> -
- - -
-
-
- )} ) } diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx deleted file mode 100644 index 040da1d421..0000000000 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ /dev/null @@ -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 ( - - ) -} - -export default OpenAiModelPicker diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx deleted file mode 100644 index c773478e54..0000000000 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ModelPicker } from "./ModelPicker" -import { openRouterDefaultModelId } from "../../../../src/shared/api" - -export const OpenRouterModelPicker = () => ( - -) diff --git a/webview-ui/src/components/settings/RequestyModelPicker.tsx b/webview-ui/src/components/settings/RequestyModelPicker.tsx deleted file mode 100644 index c65067068a..0000000000 --- a/webview-ui/src/components/settings/RequestyModelPicker.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 761e856521..d3e65a99ea 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -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(({ onDone }, ref) => { const extensionState = useExtensionState() - const [apiErrorMessage, setApiErrorMessage] = useState(undefined) - const [modelIdErrorMessage, setModelIdErrorMessage] = useState(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(undefined) // TODO: Reduce WebviewMessage/ExtensionState complexity const { currentApiConfigName } = extensionState const { - apiConfiguration, alwaysAllowReadOnly, allowedCommands, alwaysAllowBrowser, @@ -70,17 +67,19 @@ const SettingsView = forwardRef(({ 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(({ 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(({ 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(({ 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(({ 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(({ 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(({ onDone }, [isChangeDetected], ) - useImperativeHandle( - ref, - () => ({ - checkUnsaveChanges, - }), - [checkUnsaveChanges], - ) + useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges]) const onConfirmDialogResult = useCallback((confirm: boolean) => { if (confirm) { @@ -228,10 +193,7 @@ const SettingsView = forwardRef(({ 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(({ onDone }, justifyContent: "space-between", gap: "6px", }}> - + disabled={!isChangeDetected || !isSettingValid}> Save - + (({ onDone }, uriScheme={extensionState.uriScheme} apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} - apiErrorMessage={apiErrorMessage} - modelIdErrorMessage={modelIdErrorMessage} + errorMessage={errorMessage} + setErrorMessage={setErrorMessage} /> diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx new file mode 100644 index 0000000000..efaa90dc39 --- /dev/null +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -0,0 +1,29 @@ +import { Slider } from "@/components/ui" + +import { ApiConfiguration, ModelInfo, THINKING_BUDGET } from "../../../../src/shared/api" + +interface ThinkingBudgetProps { + apiConfiguration: ApiConfiguration + setApiConfigurationField: (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 ? ( +
+
Thinking Budget
+
+ setApiConfigurationField("anthropicThinking", value[0])} + /> +
{budget}
+
+
+ ) : null +} diff --git a/webview-ui/src/components/settings/UnboundModelPicker.tsx b/webview-ui/src/components/settings/UnboundModelPicker.tsx deleted file mode 100644 index 4901884f1e..0000000000 --- a/webview-ui/src/components/settings/UnboundModelPicker.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ModelPicker } from "./ModelPicker" -import { unboundDefaultModelId } from "../../../../src/shared/api" - -export const UnboundModelPicker = () => ( - -) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx index 8f2d0dff89..73394bae10 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx @@ -51,6 +51,8 @@ describe("ApiOptions", () => { render( {}} 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 }) diff --git a/webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx b/webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx index 4e7c67c187..49d60c55c4 100644 --- a/webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx @@ -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() }) @@ -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) }) }) diff --git a/webview-ui/src/components/ui/alert-dialog.tsx b/webview-ui/src/components/ui/alert-dialog.tsx index 7530cae54d..82a25bf8f7 100644 --- a/webview-ui/src/components/ui/alert-dialog.tsx +++ b/webview-ui/src/components/ui/alert-dialog.tsx @@ -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) { + return +} -const AlertDialogTrigger = AlertDialogPrimitive.Trigger +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return +} -const AlertDialogPortal = AlertDialogPrimitive.Portal +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return +} -const AlertDialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - ) { + return ( + - -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + ) +} -const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -AlertDialogHeader.displayName = "AlertDialogHeader" +function AlertDialogContent({ className, ...props }: React.ComponentProps) { + return ( + + + + + ) +} -const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -AlertDialogFooter.displayName = "AlertDialogFooter" +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} -const AlertDialogAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName +function AlertDialogAction({ className, ...props }: React.ComponentProps) { + return +} + +function AlertDialogCancel({ className, ...props }: React.ComponentProps) { + return +} export { AlertDialog, diff --git a/webview-ui/src/components/ui/combobox-primitive.tsx b/webview-ui/src/components/ui/combobox-primitive.tsx new file mode 100644 index 0000000000..13bad87aba --- /dev/null +++ b/webview-ui/src/components/ui/combobox-primitive.tsx @@ -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) => void + open: boolean + onOpenChange: (open: boolean) => void + currentTabStopId: string | null + onCurrentTabStopIdChange: (currentTabStopId: string | null) => void + inputRef: React.RefObject + tagGroupRef: React.RefObject> + disabled?: boolean + required?: boolean +} & ( + | Required> + | Required> +) + +const ComboboxContext = React.createContext({ + 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, + Omit, "value" | "defaultValue" | "onValueChange"> { + type?: ComboboxType | undefined + inputValue?: string + defaultInputValue?: string + onInputValueChange?: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void + onInputBlur?: (e: React.FocusEvent) => void + disabled?: boolean + required?: boolean +} + +export type ComboboxValue = 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( + ( + { + 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>, + ) => { + const [value = type === "multiple" ? [] : "", setValue] = useControllableState>({ + prop: valueProp as ComboboxValue, + defaultProp: defaultValue as ComboboxValue, + onChange: onValueChange as (value: ComboboxValue) => 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(null) + const inputRef = React.useRef(null) + const tagGroupRef = React.useRef>(null) + + const handleInputValueChange: ComboboxContextProps["onInputValueChange"] = React.useCallback( + (inputValue, reason) => { + setInputValue(inputValue) + onInputValueChange?.(inputValue, reason) + }, + [setInputValue, onInputValueChange], + ) + + return ( + + + + {children} + {!open && + + + ) + }, +) +Combobox.displayName = "Combobox" + +export const ComboboxTagGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + const { currentTabStopId, onCurrentTabStopIdChange, tagGroupRef, type } = useComboboxContext() + + if (type !== "multiple") { + throw new Error(' should only be used when type is "multiple"') + } + + const composedRefs = useComposedRefs(ref, tagGroupRef) + + return ( + onCurrentTabStopIdChange(null)} + {...props} + /> + ) +}) +ComboboxTagGroup.displayName = "ComboboxTagGroup" + +export interface ComboboxTagGroupItemProps + extends React.ComponentPropsWithoutRef { + value: string + disabled?: boolean +} + +const ComboboxTagGroupItemContext = React.createContext>({ + value: "", + disabled: false, +}) + +const useComboboxTagGroupItemContext = () => React.useContext(ComboboxTagGroupItemContext) + +export const ComboboxTagGroupItem = React.forwardRef< + React.ElementRef, + ComboboxTagGroupItemProps +>(({ onClick, onKeyDown, value: valueProp, disabled, ...props }, ref) => { + const { value, onValueChange, inputRef, currentTabStopId, type } = useComboboxContext() + + if (type !== "multiple") { + throw new Error(' should only be used when type is "multiple"') + } + + const lastItemValue = value.at(-1) + + return ( + + { + 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} + /> + + ) +}) +ComboboxTagGroupItem.displayName = "ComboboxTagGroupItem" + +export const ComboboxTagGroupItemRemove = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ onClick, ...props }, ref) => { + const { value, onValueChange, type } = useComboboxContext() + + if (type !== "multiple") { + throw new Error(' should only be used when type is "multiple"') + } + + const { value: valueProp, disabled } = useComboboxTagGroupItemContext() + + return ( + onValueChange(value.filter((v) => v !== valueProp)))} + {...props} + /> + ) +}) +ComboboxTagGroupItemRemove.displayName = "ComboboxTagGroupItemRemove" + +export const ComboboxInput = React.forwardRef< + React.ElementRef, + Omit, "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 ( + { + 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, + React.ComponentPropsWithoutRef +>(({ onClick, ...props }, ref) => { + const { value, onValueChange, inputValue, onInputValueChange, type } = useComboboxContext() + + const isValueEmpty = type === "single" ? !value : !value.length + + return ( + { + 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, + React.ComponentPropsWithoutRef +>(({ children, onOpenAutoFocus, onInteractOutside, ...props }, ref) => ( + 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}> + {children} + +)) +ComboboxContent.displayName = "ComboboxContent" + +export const ComboboxEmpty = CommandPrimitive.Empty + +export const ComboboxLoading = CommandPrimitive.Loading + +export interface ComboboxItemProps extends Omit, "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, 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 ( + + 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} + + + ) + }, +) +ComboboxItem.displayName = "ComboboxItem" + +export const ComboboxItemIndicator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + const { isSelected } = useComboboxItemContext() + + if (!isSelected) { + return null + } + + return +}) +ComboboxItemIndicator.displayName = "ComboboxItemIndicator" + +export interface ComboboxItemTextProps extends React.ComponentPropsWithoutRef { + children: string +} + +export const ComboboxItemText = (props: ComboboxItemTextProps) => +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, +} diff --git a/webview-ui/src/components/ui/combobox.tsx b/webview-ui/src/components/ui/combobox.tsx new file mode 100644 index 0000000000..24b2f7be1f --- /dev/null +++ b/webview-ui/src/components/ui/combobox.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ children, ...props }, ref) => ( + + + {children} + + + + + + + + + + + + +)) +ComboboxInputBase.displayName = "ComboboxInputBase" + +export const ComboboxInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + + + + + + +)) +ComboboxInput.displayName = "ComboboxInput" + +export const ComboboxTagsInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, ...props }, ref) => ( + + + + {children} + + + + + + + + +)) +ComboboxTagsInput.displayName = "ComboboxTagsInput" + +export const ComboboxTag = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, className, ...props }, ref) => ( + + {children} + + + Remove + + +)) +ComboboxTag.displayName = "ComboboxTag" + +export const ComboboxContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "start", alignOffset = 0, ...props }, ref) => ( + + + +)) +ComboboxContent.displayName = "ComboboxContent" + +export const ComboboxEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ComboboxEmpty.displayName = "ComboboxEmpty" + +export const ComboboxLoading = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ComboboxLoading.displayName = "ComboboxLoading" + +export const ComboboxGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ComboboxGroup.displayName = "ComboboxGroup" + +const ComboboxSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +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, + Omit, "children"> & + Pick, "children"> +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)) +ComboboxItem.displayName = "ComboboxItem" diff --git a/webview-ui/src/components/ui/dialog.tsx b/webview-ui/src/components/ui/dialog.tsx index 11d5e2d3b0..ed3160f692 100644 --- a/webview-ui/src/components/ui/dialog.tsx +++ b/webview-ui/src/components/ui/dialog.tsx @@ -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) { + return +} -const DialogTrigger = DialogPrimitive.Trigger +function DialogTrigger({ ...props }: React.ComponentProps) { + return +} -const DialogPortal = DialogPrimitive.Portal +function DialogPortal({ ...props }: React.ComponentProps) { + return +} -const DialogClose = DialogPrimitive.Close +function DialogClose({ ...props }: React.ComponentProps) { + return +} -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - ) { + return ( + - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName + {...props} + /> + ) +} -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = "DialogHeader" +function DialogContent({ className, children, ...props }: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = "DialogFooter" +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ) +} export { Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, DialogClose, DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, } diff --git a/webview-ui/src/components/ui/input-base.tsx b/webview-ui/src/components/ui/input-base.tsx new file mode 100644 index 0000000000..9dbda6eb13 --- /dev/null +++ b/webview-ui/src/components/ui/input-base.tsx @@ -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 & { + controlRef: React.RefObject + onFocusedChange: (focused: boolean) => void +} + +const InputBaseContext = React.createContext({ + autoFocus: false, + controlRef: { current: null }, + disabled: false, + onFocusedChange: () => {}, +}) + +const useInputBaseContext = () => React.useContext(InputBaseContext) + +export interface InputBaseProps extends React.ComponentPropsWithoutRef { + autoFocus?: boolean + disabled?: boolean +} + +export const InputBase = React.forwardRef, 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(null) + + return ( + + { + // Based on MUI's 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} + /> + + ) + }, +) +InputBase.displayName = "InputBase" + +export const InputBaseFlexWrapper = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +InputBaseFlexWrapper.displayName = "InputBaseFlexWrapper" + +export const InputBaseControl = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ onFocus, onBlur, ...props }, ref) => { + const { controlRef, autoFocus, disabled, onFocusedChange } = useInputBaseContext() + + return ( + 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, 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 ( + + {children} + + ) + }, +) +InputBaseAdornment.displayName = "InputBaseAdornment" + +export const InputBaseAdornmentButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ type = "button", variant = "ghost", size = "icon", disabled: disabledProp, className, ...props }, ref) => { + const { disabled } = useInputBaseContext() + + return ( +
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 3dca8d5f51..ae5c5b9539 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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 - requestyModels: Record - openRouterModels: Record - unboundModels: Record - 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(undefined) const [filePaths, setFilePaths] = useState([]) - const [glamaModels, setGlamaModels] = useState>({ - [glamaDefaultModelId]: glamaDefaultModelInfo, - }) const [openedTabs, setOpenedTabs] = useState>([]) - const [openRouterModels, setOpenRouterModels] = useState>({ - [openRouterDefaultModelId]: openRouterDefaultModelInfo, - }) - const [unboundModels, setUnboundModels] = useState>({ - [unboundDefaultModelId]: unboundDefaultModelInfo, - }) - const [requestyModels, setRequestyModels] = useState>({ - [requestyDefaultModelId]: requestyDefaultModelInfo, - }) - const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) const [currentCheckpoint, setCurrentCheckpoint] = useState() @@ -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 })), diff --git a/webview-ui/src/utils/__tests__/path-mentions.test.ts b/webview-ui/src/utils/__tests__/path-mentions.test.ts new file mode 100644 index 0000000000..bb5591fbe5 --- /dev/null +++ b/webview-ui/src/utils/__tests__/path-mentions.test.ts @@ -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", + ) + }) + }) +}) diff --git a/webview-ui/src/utils/path-mentions.ts b/webview-ui/src/utils/path-mentions.ts new file mode 100644 index 0000000000..960483f593 --- /dev/null +++ b/webview-ui/src/utils/path-mentions.ts @@ -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 +} diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 19b13e2c6c..82af23ab49 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -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, openRouterModels?: Record, unboundModels?: Record, + requestyModels?: Record, ): 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 }