From 9b267e9afc8cc4308e5decfa0a9c312caaafc4bf Mon Sep 17 00:00:00 2001 From: Aitor Oses Date: Tue, 25 Feb 2025 15:30:10 +0100 Subject: [PATCH 01/28] Add Vertex AI prompt caching support and enhance streaming handling - Implemented comprehensive prompt caching strategy for Vertex AI models - Added support for caching system prompts and user message text blocks - Enhanced stream processing to handle cache-related usage metrics - Updated model configurations to enable prompt caching - Improved type definitions for Vertex AI message handling --- src/api/providers/__tests__/vertex.test.ts | 215 ++++++++++++++++++- src/api/providers/vertex.ts | 235 ++++++++++++++++++--- src/shared/api.ts | 20 +- 3 files changed, 435 insertions(+), 35 deletions(-) diff --git a/src/api/providers/__tests__/vertex.test.ts b/src/api/providers/__tests__/vertex.test.ts index ebe60ba0c6..6e81fd771b 100644 --- a/src/api/providers/__tests__/vertex.test.ts +++ b/src/api/providers/__tests__/vertex.test.ts @@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" import { VertexHandler } from "../vertex" +import { ApiStreamChunk } from "../../transform/stream" // Mock Vertex SDK jest.mock("@anthropic-ai/vertex-sdk", () => ({ @@ -128,7 +129,7 @@ describe("VertexHandler", () => { ;(handler["client"].messages as any).create = mockCreate const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) @@ -158,8 +159,29 @@ describe("VertexHandler", () => { model: "claude-3-5-sonnet-v2@20241022", max_tokens: 8192, temperature: 0, - system: systemPrompt, - messages: mockMessages, + system: [ + { + type: "text", + text: "You are a helpful assistant", + cache_control: { type: "ephemeral" }, + }, + ], + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "Hello", + cache_control: { type: "ephemeral" }, + }, + ], + }, + { + role: "assistant", + content: "Hi there!", + }, + ], stream: true, }) }) @@ -196,7 +218,7 @@ describe("VertexHandler", () => { ;(handler["client"].messages as any).create = mockCreate const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) @@ -230,6 +252,183 @@ describe("VertexHandler", () => { } }).rejects.toThrow("Vertex API error") }) + + it("should handle prompt caching for supported models", async () => { + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + cache_creation_input_tokens: 3, + cache_read_input_tokens: 2, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "text", + text: "Hello", + }, + }, + { + type: "content_block_delta", + delta: { + type: "text_delta", + text: " world!", + }, + }, + { + type: "message_delta", + usage: { + output_tokens: 5, + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, [ + { + role: "user", + content: "First message", + }, + { + role: "assistant", + content: "Response", + }, + { + role: "user", + content: "Second message", + }, + ]) + + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify usage information + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(2) + expect(usageChunks[0]).toEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 0, + cacheWriteTokens: 3, + cacheReadTokens: 2, + }) + expect(usageChunks[1]).toEqual({ + type: "usage", + inputTokens: 0, + outputTokens: 5, + }) + + // Verify text content + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe("Hello") + expect(textChunks[1].text).toBe(" world!") + + // Verify cache control was added correctly + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + system: [ + { + type: "text", + text: "You are a helpful assistant", + cache_control: { type: "ephemeral" }, + }, + ], + messages: [ + expect.objectContaining({ + role: "user", + content: [ + { + type: "text", + text: "First message", + cache_control: { type: "ephemeral" }, + }, + ], + }), + expect.objectContaining({ + role: "assistant", + content: "Response", + }), + expect.objectContaining({ + role: "user", + content: [ + { + type: "text", + text: "Second message", + cache_control: { type: "ephemeral" }, + }, + ], + }), + ], + }), + ) + }) + + it("should handle cache-related usage metrics", async () => { + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 3, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "text", + text: "Hello", + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks: ApiStreamChunk[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Check for cache-related metrics in usage chunk + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0]).toHaveProperty("cacheWriteTokens", 5) + expect(usageChunks[0]).toHaveProperty("cacheReadTokens", 3) + }) }) describe("completePrompt", () => { @@ -240,7 +439,13 @@ describe("VertexHandler", () => { model: "claude-3-5-sonnet-v2@20241022", max_tokens: 8192, temperature: 0, - messages: [{ role: "user", content: "Test prompt" }], + system: "", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }], + }, + ], stream: false, }) }) diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 0ee22e5893..70562766c3 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -1,9 +1,86 @@ import { Anthropic } from "@anthropic-ai/sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" +import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming" import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api" import { ApiStream } from "../transform/stream" +// Types for Vertex SDK + +/** + * Vertex API has specific limitations for prompt caching: + * 1. Maximum of 4 blocks can have cache_control + * 2. Only text blocks can be cached (images and other content types cannot) + * 3. Cache control can only be applied to user messages, not assistant messages + * + * Our caching strategy: + * - Cache the system prompt (1 block) + * - Cache the last text block of the second-to-last user message (1 block) + * - Cache the last text block of the last user message (1 block) + * This ensures we stay under the 4-block limit while maintaining effective caching + * for the most relevant context. + */ + +interface VertexTextBlock { + type: "text" + text: string + cache_control?: { type: "ephemeral" } +} + +interface VertexImageBlock { + type: "image" + source: { + type: "base64" + media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp" + data: string + } +} + +type VertexContentBlock = VertexTextBlock | VertexImageBlock + +interface VertexUsage { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +interface VertexMessage extends Omit { + content: string | VertexContentBlock[] +} + +interface VertexMessageCreateParams { + model: string + max_tokens: number + temperature: number + system: string | VertexTextBlock[] + messages: VertexMessage[] + stream: boolean +} + +interface VertexMessageResponse { + content: Array<{ type: "text"; text: string }> +} + +interface VertexMessageStreamEvent { + type: "message_start" | "message_delta" | "content_block_start" | "content_block_delta" + message?: { + usage: VertexUsage + } + usage?: { + output_tokens: number + } + content_block?: { + type: "text" + text: string + } + index?: number + delta?: { + type: "text_delta" + text: string + } +} + // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class VertexHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions @@ -18,37 +95,120 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { }) } + private formatMessageForCache(message: Anthropic.Messages.MessageParam, shouldCache: boolean): VertexMessage { + // Assistant messages are kept as-is since they can't be cached + if (message.role === "assistant") { + return message as VertexMessage + } + + // For string content, we convert to array format with optional cache control + if (typeof message.content === "string") { + return { + ...message, + content: [ + { + type: "text" as const, + text: message.content, + // For string content, we only have one block so it's always the last + ...(shouldCache && { cache_control: { type: "ephemeral" } }), + }, + ], + } + } + + // For array content, find the last text block index once before mapping + const lastTextBlockIndex = message.content.reduce( + (lastIndex, content, index) => (content.type === "text" ? index : lastIndex), + -1, + ) + + // Then use this pre-calculated index in the map function + return { + ...message, + content: message.content.map((content, contentIndex) => { + // Images and other non-text content are passed through unchanged + if (content.type === "image") { + return content as VertexImageBlock + } + + // Check if this is the last text block using our pre-calculated index + const isLastTextBlock = contentIndex === lastTextBlockIndex + + return { + type: "text" as const, + text: (content as { text: string }).text, + ...(shouldCache && isLastTextBlock && { cache_control: { type: "ephemeral" } }), + } + }), + } + } + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const stream = await this.client.messages.create({ - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens || 8192, + const model = this.getModel() + const useCache = model.info.supportsPromptCache + + // Find indices of user messages that we want to cache + // We only cache the last two user messages to stay within the 4-block limit + // (1 block for system + 1 block each for last two user messages = 3 total) + const userMsgIndices = useCache + ? messages.reduce((acc, msg, i) => (msg.role === "user" ? [...acc, i] : acc), [] as number[]) + : [] + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + + // Create the stream with appropriate caching configuration + const params = { + model: model.id, + max_tokens: model.info.maxTokens || 8192, temperature: this.options.modelTemperature ?? 0, - system: systemPrompt, - messages, + // Cache the system prompt if caching is enabled + system: useCache + ? [ + { + text: systemPrompt, + type: "text" as const, + cache_control: { type: "ephemeral" }, + }, + ] + : systemPrompt, + messages: messages.map((message, index) => { + // Only cache the last two user messages + const shouldCache = useCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex) + return this.formatMessageForCache(message, shouldCache) + }), stream: true, - }) + } + + const stream = (await this.client.messages.create( + params as Anthropic.Messages.MessageCreateParamsStreaming, + )) as unknown as AnthropicStream + + // Process the stream chunks for await (const chunk of stream) { switch (chunk.type) { - case "message_start": - const usage = chunk.message.usage + case "message_start": { + const usage = chunk.message!.usage yield { type: "usage", inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0, + cacheWriteTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, } break - case "message_delta": + } + case "message_delta": { yield { type: "usage", inputTokens: 0, - outputTokens: chunk.usage.output_tokens || 0, + outputTokens: chunk.usage!.output_tokens || 0, } break - - case "content_block_start": - switch (chunk.content_block.type) { - case "text": - if (chunk.index > 0) { + } + case "content_block_start": { + switch (chunk.content_block!.type) { + case "text": { + if (chunk.index! > 0) { yield { type: "text", text: "\n", @@ -56,21 +216,25 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { } yield { type: "text", - text: chunk.content_block.text, + text: chunk.content_block!.text, } break + } } break - case "content_block_delta": - switch (chunk.delta.type) { - case "text_delta": + } + case "content_block_delta": { + switch (chunk.delta!.type) { + case "text_delta": { yield { type: "text", - text: chunk.delta.text, + text: chunk.delta!.text, } break + } } break + } } } } @@ -86,13 +250,34 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { async completePrompt(prompt: string): Promise { try { - const response = await this.client.messages.create({ - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens || 8192, + const model = this.getModel() + const useCache = model.info.supportsPromptCache + + const params = { + model: model.id, + max_tokens: model.info.maxTokens || 8192, temperature: this.options.modelTemperature ?? 0, - messages: [{ role: "user", content: prompt }], + system: "", // No system prompt needed for single completions + messages: [ + { + role: "user", + content: useCache + ? [ + { + type: "text" as const, + text: prompt, + cache_control: { type: "ephemeral" }, + }, + ] + : prompt, + }, + ], stream: false, - }) + } + + const response = (await this.client.messages.create( + params as Anthropic.Messages.MessageCreateParamsNonStreaming, + )) as unknown as VertexMessageResponse const content = response.content[0] if (content.type === "text") { diff --git a/src/shared/api.ts b/src/shared/api.ts index cea760c776..95399cca4a 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -435,41 +435,51 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 3.0, outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, }, "claude-3-5-sonnet@20240620": { maxTokens: 8192, contextWindow: 200_000, supportsImages: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 3.0, outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, }, "claude-3-5-haiku@20241022": { maxTokens: 8192, contextWindow: 200_000, supportsImages: false, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 1.0, outputPrice: 5.0, + cacheWritesPrice: 1.25, + cacheReadsPrice: 0.1, }, "claude-3-opus@20240229": { maxTokens: 4096, contextWindow: 200_000, supportsImages: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 15.0, outputPrice: 75.0, + cacheWritesPrice: 18.75, + cacheReadsPrice: 1.5, }, "claude-3-haiku@20240307": { maxTokens: 4096, contextWindow: 200_000, supportsImages: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 0.25, outputPrice: 1.25, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.03, }, } as const satisfies Record From 5e53d00ebcf0d2adf218a07452d0e15835bf3e64 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Wed, 26 Feb 2025 14:23:11 -0800 Subject: [PATCH 02/28] Allow control over maxTokens for thinking models --- src/api/providers/anthropic.ts | 12 ++- src/api/providers/openrouter.ts | 13 ++- src/core/Cline.ts | 18 +++- .../__tests__/sliding-window.test.ts | 102 +++++++++++++++--- src/core/sliding-window/index.ts | 37 ++++--- src/core/webview/ClineProvider.ts | 5 + src/shared/api.ts | 11 +- src/shared/globalState.ts | 1 + .../components/settings/ThinkingBudget.tsx | 63 ++++++++--- 9 files changed, 197 insertions(+), 65 deletions(-) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index ad58a1cf6b..8c5a1795b1 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -31,7 +31,7 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler { let stream: AnthropicStream const cacheControl: CacheControlEphemeral = { type: "ephemeral" } let { id: modelId, info: modelInfo } = this.getModel() - const maxTokens = modelInfo.maxTokens || 8192 + const maxTokens = this.options.modelMaxTokens || modelInfo.maxTokens || 8192 let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE let thinking: BetaThinkingConfigParam | undefined = undefined @@ -41,7 +41,15 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler { // `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) + + // Clamp the thinking budget to be at most 80% of max tokens and at + // least 1024 tokens. + const maxBudgetTokens = Math.floor(maxTokens * 0.8) + const budgetTokens = Math.max( + Math.min(this.options.anthropicThinking ?? maxBudgetTokens, maxBudgetTokens), + 1024, + ) + thinking = { type: "enabled", budget_tokens: budgetTokens } temperature = 1.0 } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 0a9488e816..69bcb0074c 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -108,12 +108,19 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler { topP = 0.95 } + const maxTokens = this.options.modelMaxTokens || modelInfo.maxTokens 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) + // Clamp the thinking budget to be at most 80% of max tokens and at + // least 1024 tokens. + const maxBudgetTokens = Math.floor((maxTokens || 8192) * 0.8) + const budgetTokens = Math.max( + Math.min(this.options.anthropicThinking ?? maxBudgetTokens, maxBudgetTokens), + 1024, + ) + thinking = { type: "enabled", budget_tokens: budgetTokens } temperature = 1.0 } @@ -271,7 +278,7 @@ export async function getOpenRouterModels() { modelInfo.supportsPromptCache = true modelInfo.cacheWritesPrice = 3.75 modelInfo.cacheReadsPrice = 0.3 - modelInfo.maxTokens = 16384 + modelInfo.maxTokens = 64_000 break case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"): modelInfo.supportsPromptCache = true diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 073bd10911..fb123e0584 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -87,6 +87,7 @@ export type ClineOptions = { export class Cline { readonly taskId: string + readonly apiConfiguration: ApiConfiguration api: ApiHandler private terminalManager: TerminalManager private urlContentFetcher: UrlContentFetcher @@ -148,6 +149,7 @@ export class Cline { } this.taskId = crypto.randomUUID() + this.apiConfiguration = apiConfiguration this.api = buildApiHandler(apiConfiguration) this.terminalManager = new TerminalManager() this.urlContentFetcher = new UrlContentFetcher(provider.context) @@ -961,13 +963,21 @@ export class Cline { cacheWrites = 0, cacheReads = 0, }: ClineApiReqInfo = JSON.parse(previousRequest) + const totalTokens = tokensIn + tokensOut + cacheWrites + cacheReads - const trimmedMessages = truncateConversationIfNeeded( - this.apiConversationHistory, + const modelInfo = this.api.getModel().info + const maxTokens = modelInfo.thinking + ? this.apiConfiguration.modelMaxTokens || modelInfo.maxTokens + : modelInfo.maxTokens + const contextWindow = modelInfo.contextWindow + + const trimmedMessages = truncateConversationIfNeeded({ + messages: this.apiConversationHistory, totalTokens, - this.api.getModel().info, - ) + maxTokens, + contextWindow, + }) if (trimmedMessages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(trimmedMessages) diff --git a/src/core/sliding-window/__tests__/sliding-window.test.ts b/src/core/sliding-window/__tests__/sliding-window.test.ts index 3dcf9e5fd2..cb897aa8cb 100644 --- a/src/core/sliding-window/__tests__/sliding-window.test.ts +++ b/src/core/sliding-window/__tests__/sliding-window.test.ts @@ -119,11 +119,21 @@ describe("getMaxTokens", () => { // Max tokens = 100000 - 50000 = 50000 // Below max tokens - no truncation - const result1 = truncateConversationIfNeeded(messages, 49999, modelInfo) + const result1 = truncateConversationIfNeeded({ + messages, + totalTokens: 49999, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result1).toEqual(messages) // Above max tokens - truncate - const result2 = truncateConversationIfNeeded(messages, 50001, modelInfo) + const result2 = truncateConversationIfNeeded({ + messages, + totalTokens: 50001, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result2).not.toEqual(messages) expect(result2.length).toBe(3) // Truncated with 0.5 fraction }) @@ -133,11 +143,21 @@ describe("getMaxTokens", () => { // Max tokens = 100000 - (100000 * 0.2) = 80000 // Below max tokens - no truncation - const result1 = truncateConversationIfNeeded(messages, 79999, modelInfo) + const result1 = truncateConversationIfNeeded({ + messages, + totalTokens: 79999, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result1).toEqual(messages) // Above max tokens - truncate - const result2 = truncateConversationIfNeeded(messages, 80001, modelInfo) + const result2 = truncateConversationIfNeeded({ + messages, + totalTokens: 80001, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result2).not.toEqual(messages) expect(result2.length).toBe(3) // Truncated with 0.5 fraction }) @@ -147,11 +167,21 @@ describe("getMaxTokens", () => { // Max tokens = 50000 - 10000 = 40000 // Below max tokens - no truncation - const result1 = truncateConversationIfNeeded(messages, 39999, modelInfo) + const result1 = truncateConversationIfNeeded({ + messages, + totalTokens: 39999, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result1).toEqual(messages) // Above max tokens - truncate - const result2 = truncateConversationIfNeeded(messages, 40001, modelInfo) + const result2 = truncateConversationIfNeeded({ + messages, + totalTokens: 40001, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result2).not.toEqual(messages) expect(result2.length).toBe(3) // Truncated with 0.5 fraction }) @@ -161,11 +191,21 @@ describe("getMaxTokens", () => { // Max tokens = 200000 - 30000 = 170000 // Below max tokens - no truncation - const result1 = truncateConversationIfNeeded(messages, 169999, modelInfo) + const result1 = truncateConversationIfNeeded({ + messages, + totalTokens: 169999, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result1).toEqual(messages) // Above max tokens - truncate - const result2 = truncateConversationIfNeeded(messages, 170001, modelInfo) + const result2 = truncateConversationIfNeeded({ + messages, + totalTokens: 170001, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result2).not.toEqual(messages) expect(result2.length).toBe(3) // Truncated with 0.5 fraction }) @@ -194,7 +234,12 @@ describe("truncateConversationIfNeeded", () => { const maxTokens = 100000 - 30000 // 70000 const totalTokens = 69999 // Below threshold - const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo) + const result = truncateConversationIfNeeded({ + messages, + totalTokens, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result).toEqual(messages) // No truncation occurs }) @@ -207,7 +252,12 @@ describe("truncateConversationIfNeeded", () => { // 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) + const result = truncateConversationIfNeeded({ + messages, + totalTokens, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result).toEqual(expectedResult) }) @@ -218,14 +268,38 @@ describe("truncateConversationIfNeeded", () => { // Test below threshold const belowThreshold = 69999 - expect(truncateConversationIfNeeded(messages, belowThreshold, modelInfo1)).toEqual( - truncateConversationIfNeeded(messages, belowThreshold, modelInfo2), + expect( + truncateConversationIfNeeded({ + messages, + totalTokens: belowThreshold, + contextWindow: modelInfo1.contextWindow, + maxTokens: modelInfo1.maxTokens, + }), + ).toEqual( + truncateConversationIfNeeded({ + messages, + totalTokens: belowThreshold, + contextWindow: modelInfo2.contextWindow, + maxTokens: modelInfo2.maxTokens, + }), ) // Test above threshold const aboveThreshold = 70001 - expect(truncateConversationIfNeeded(messages, aboveThreshold, modelInfo1)).toEqual( - truncateConversationIfNeeded(messages, aboveThreshold, modelInfo2), + expect( + truncateConversationIfNeeded({ + messages, + totalTokens: aboveThreshold, + contextWindow: modelInfo1.contextWindow, + maxTokens: modelInfo1.maxTokens, + }), + ).toEqual( + truncateConversationIfNeeded({ + messages, + totalTokens: aboveThreshold, + contextWindow: modelInfo2.contextWindow, + maxTokens: modelInfo2.maxTokens, + }), ) }) }) diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index a0fff05ea5..8b646f933b 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -1,7 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { ModelInfo } from "../../shared/api" - /** * Truncates a conversation by removing a fraction of the messages. * @@ -26,28 +24,29 @@ export function truncateConversation( } /** - * Conditionally truncates the conversation messages if the total token count exceeds the model's limit. + * Conditionally truncates the conversation messages if the total token count + * exceeds the model's limit. * * @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. + * @param {number} contextWindow - The context window size. + * @param {number} maxTokens - The maximum number of tokens allowed. * @returns {Anthropic.Messages.MessageParam[]} The original or truncated conversation messages. */ -export function truncateConversationIfNeeded( - messages: Anthropic.Messages.MessageParam[], - totalTokens: number, - modelInfo: ModelInfo, -): Anthropic.Messages.MessageParam[] { - return totalTokens < getMaxTokens(modelInfo) ? messages : truncateConversation(messages, 0.5) + +type TruncateOptions = { + messages: Anthropic.Messages.MessageParam[] + totalTokens: number + contextWindow: number + maxTokens?: number } -/** - * Calculates the maximum allowed tokens - * - * @param {ModelInfo} modelInfo - The model information containing the context window size. - * @returns {number} The maximum number of tokens allowed - */ -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) +export function truncateConversationIfNeeded({ + messages, + totalTokens, + contextWindow, + maxTokens, +}: TruncateOptions): Anthropic.Messages.MessageParam[] { + const allowedTokens = contextWindow - (maxTokens || contextWindow * 0.2) + return totalTokens < allowedTokens ? messages : truncateConversation(messages, 0.5) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bc6f457868..5e6170e2ee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1671,6 +1671,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelId, requestyModelInfo, modelTemperature, + modelMaxTokens, } = apiConfiguration await Promise.all([ this.updateGlobalState("apiProvider", apiProvider), @@ -1719,6 +1720,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("requestyModelId", requestyModelId), this.updateGlobalState("requestyModelInfo", requestyModelInfo), this.updateGlobalState("modelTemperature", modelTemperature), + this.updateGlobalState("modelMaxTokens", modelMaxTokens), ]) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) @@ -2210,6 +2212,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelId, requestyModelInfo, modelTemperature, + modelMaxTokens, maxOpenTabsContext, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, @@ -2293,6 +2296,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("requestyModelId") as Promise, this.getGlobalState("requestyModelInfo") as Promise, this.getGlobalState("modelTemperature") as Promise, + this.getGlobalState("modelMaxTokens") as Promise, this.getGlobalState("maxOpenTabsContext") as Promise, ]) @@ -2358,6 +2362,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelId, requestyModelInfo, modelTemperature, + modelMaxTokens, }, lastShownAnnouncementId, customInstructions, diff --git a/src/shared/api.ts b/src/shared/api.ts index 5d4b8b120d..e7e4c54db6 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -68,6 +68,7 @@ export interface ApiHandlerOptions { requestyModelId?: string requestyModelInfo?: ModelInfo modelTemperature?: number + modelMaxTokens?: number } export type ApiConfiguration = ApiHandlerOptions & { @@ -92,19 +93,13 @@ export interface ModelInfo { thinking?: boolean } -export const THINKING_BUDGET = { - step: 1024, - min: 1024, - default: 8 * 1024, -} - // Anthropic // https://docs.anthropic.com/en/docs/about-claude/models export type AnthropicModelId = keyof typeof anthropicModels export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219" export const anthropicModels = { "claude-3-7-sonnet-20250219:thinking": { - maxTokens: 16384, + maxTokens: 64_000, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, @@ -116,7 +111,7 @@ export const anthropicModels = { thinking: true, }, "claude-3-7-sonnet-20250219": { - maxTokens: 16384, + maxTokens: 64_000, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 7b6b4f8274..2cc90456a7 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -81,5 +81,6 @@ export type GlobalStateKey = | "requestyModelInfo" | "unboundModelInfo" | "modelTemperature" + | "modelMaxTokens" | "mistralCodestralUrl" | "maxOpenTabsContext" diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index efaa90dc39..5b67874410 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -1,6 +1,8 @@ +import { useEffect } from "react" + import { Slider } from "@/components/ui" -import { ApiConfiguration, ModelInfo, THINKING_BUDGET } from "../../../../src/shared/api" +import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api" interface ThinkingBudgetProps { apiConfiguration: ApiConfiguration @@ -9,21 +11,52 @@ interface ThinkingBudgetProps { } export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => { - const budget = apiConfiguration?.anthropicThinking ?? THINKING_BUDGET.default + const tokens = apiConfiguration?.modelMaxTokens || modelInfo?.maxTokens || 64_000 + const tokensMin = 8192 + const tokensMax = modelInfo?.maxTokens || 64_000 - return modelInfo && modelInfo.thinking ? ( -
-
Thinking Budget
-
- setApiConfigurationField("anthropicThinking", value[0])} - /> -
{budget}
+ const thinkingTokens = apiConfiguration?.anthropicThinking || 8192 + const thinkingTokensMin = 1024 + const thinkingTokensMax = Math.floor(0.8 * tokens) + + useEffect(() => { + if (thinkingTokens > thinkingTokensMax) { + setApiConfigurationField("anthropicThinking", thinkingTokensMax) + } + }, [thinkingTokens, thinkingTokensMax, setApiConfigurationField]) + + if (!modelInfo || !modelInfo.thinking) { + return null + } + + return ( +
+
+
Max Tokens
+
+ setApiConfigurationField("modelMaxTokens", value)} + /> +
{tokens}
+
+
+
+
Max Thinking Tokens
+
+ setApiConfigurationField("anthropicThinking", value)} + /> +
{thinkingTokens}
+
- ) : null + ) } From cf69b0fff92e8b1cff23cf4b37d8e8a2f10a34dc Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Wed, 26 Feb 2025 14:26:27 -0800 Subject: [PATCH 03/28] Add changeset --- .changeset/wild-emus-dream.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wild-emus-dream.md diff --git a/.changeset/wild-emus-dream.md b/.changeset/wild-emus-dream.md new file mode 100644 index 0000000000..19e5a4626b --- /dev/null +++ b/.changeset/wild-emus-dream.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Allow control over maxTokens for thinking models From dfa019e7f443bf9997ed2ac2556f597a59e4bf77 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 26 Feb 2025 17:34:39 -0500 Subject: [PATCH 04/28] Truncate results from search_files to 500 chars max --- .changeset/stale-cooks-help.md | 5 ++ src/services/ripgrep/__tests__/index.test.ts | 51 ++++++++++++++++++++ src/services/ripgrep/index.ts | 30 ++++++++++-- 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 .changeset/stale-cooks-help.md create mode 100644 src/services/ripgrep/__tests__/index.test.ts diff --git a/.changeset/stale-cooks-help.md b/.changeset/stale-cooks-help.md new file mode 100644 index 0000000000..8c9c714738 --- /dev/null +++ b/.changeset/stale-cooks-help.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Truncate search_file output to avoid crashing the extension diff --git a/src/services/ripgrep/__tests__/index.test.ts b/src/services/ripgrep/__tests__/index.test.ts new file mode 100644 index 0000000000..7c3549a827 --- /dev/null +++ b/src/services/ripgrep/__tests__/index.test.ts @@ -0,0 +1,51 @@ +// npx jest src/services/ripgrep/__tests__/index.test.ts + +import { describe, expect, it } from "@jest/globals" +import { truncateLine } from "../index" + +describe("Ripgrep line truncation", () => { + // The default MAX_LINE_LENGTH is 500 in the implementation + const MAX_LINE_LENGTH = 500 + + it("should truncate lines longer than MAX_LINE_LENGTH", () => { + const longLine = "a".repeat(600) // Line longer than MAX_LINE_LENGTH + const truncated = truncateLine(longLine) + + expect(truncated).toContain("[truncated...]") + expect(truncated.length).toBeLessThan(longLine.length) + expect(truncated.length).toEqual(MAX_LINE_LENGTH + " [truncated...]".length) + }) + + it("should not truncate lines shorter than MAX_LINE_LENGTH", () => { + const shortLine = "Short line of text" + const truncated = truncateLine(shortLine) + + expect(truncated).toEqual(shortLine) + expect(truncated).not.toContain("[truncated...]") + }) + + it("should correctly truncate a line at exactly MAX_LINE_LENGTH characters", () => { + const exactLine = "a".repeat(MAX_LINE_LENGTH) + const exactPlusOne = exactLine + "x" + + // Should not truncate when exactly MAX_LINE_LENGTH + expect(truncateLine(exactLine)).toEqual(exactLine) + + // Should truncate when exceeding MAX_LINE_LENGTH by even 1 character + expect(truncateLine(exactPlusOne)).toContain("[truncated...]") + }) + + it("should handle empty lines without errors", () => { + expect(truncateLine("")).toEqual("") + }) + + it("should allow custom maximum length", () => { + const customLength = 100 + const line = "a".repeat(customLength + 50) + + const truncated = truncateLine(line, customLength) + + expect(truncated.length).toEqual(customLength + " [truncated...]".length) + expect(truncated).toContain("[truncated...]") + }) +}) diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index b48c60b5b2..770c897e52 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -58,7 +58,19 @@ interface SearchResult { afterContext: string[] } +// Constants const MAX_RESULTS = 300 +const MAX_LINE_LENGTH = 500 + +/** + * Truncates a line if it exceeds the maximum length + * @param line The line to truncate + * @param maxLength The maximum allowed length (defaults to MAX_LINE_LENGTH) + * @returns The truncated line, or the original line if it's shorter than maxLength + */ +export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string { + return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line +} async function getBinPath(vscodeAppRoot: string): Promise { const checkPath = async (pkgFolder: string) => { @@ -140,7 +152,8 @@ export async function regexSearchFiles( let output: string try { output = await execRipgrep(rgPath, args) - } catch { + } catch (error) { + console.error("Error executing ripgrep:", error) return "No results found" } const results: SearchResult[] = [] @@ -154,19 +167,28 @@ export async function regexSearchFiles( if (currentResult) { results.push(currentResult as SearchResult) } + + // Safety check: truncate extremely long lines to prevent excessive output + const matchText = parsed.data.lines.text + const truncatedMatch = truncateLine(matchText) + currentResult = { file: parsed.data.path.text, line: parsed.data.line_number, column: parsed.data.submatches[0].start, - match: parsed.data.lines.text, + match: truncatedMatch, beforeContext: [], afterContext: [], } } else if (parsed.type === "context" && currentResult) { + // Apply the same truncation logic to context lines + const contextText = parsed.data.lines.text + const truncatedContext = truncateLine(contextText) + if (parsed.data.line_number < currentResult.line!) { - currentResult.beforeContext!.push(parsed.data.lines.text) + currentResult.beforeContext!.push(truncatedContext) } else { - currentResult.afterContext!.push(parsed.data.lines.text) + currentResult.afterContext!.push(truncatedContext) } } } catch (error) { From 247a50a6dcbb29512d20796b29a3240dc4b84463 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Feb 2025 23:17:23 +0000 Subject: [PATCH 05/28] changeset version bump --- .changeset/fluffy-apples-attack.md | 5 ----- .changeset/orange-zoos-train.md | 5 ----- .changeset/stale-cooks-help.md | 5 ----- .changeset/tender-cycles-help.md | 5 ----- .changeset/wild-emus-dream.md | 5 ----- CHANGELOG.md | 10 ++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 8 files changed, 13 insertions(+), 28 deletions(-) delete mode 100644 .changeset/fluffy-apples-attack.md delete mode 100644 .changeset/orange-zoos-train.md delete mode 100644 .changeset/stale-cooks-help.md delete mode 100644 .changeset/tender-cycles-help.md delete mode 100644 .changeset/wild-emus-dream.md diff --git a/.changeset/fluffy-apples-attack.md b/.changeset/fluffy-apples-attack.md deleted file mode 100644 index 924a1b2505..0000000000 --- a/.changeset/fluffy-apples-attack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Handle really long text in the ChatRow similar to TaskHeader diff --git a/.changeset/orange-zoos-train.md b/.changeset/orange-zoos-train.md deleted file mode 100644 index 76c16f4567..0000000000 --- a/.changeset/orange-zoos-train.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Support multiple files in drag-and-drop diff --git a/.changeset/stale-cooks-help.md b/.changeset/stale-cooks-help.md deleted file mode 100644 index 8c9c714738..0000000000 --- a/.changeset/stale-cooks-help.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Truncate search_file output to avoid crashing the extension diff --git a/.changeset/tender-cycles-help.md b/.changeset/tender-cycles-help.md deleted file mode 100644 index d43e423ee6..0000000000 --- a/.changeset/tender-cycles-help.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Better OpenRouter error handling diff --git a/.changeset/wild-emus-dream.md b/.changeset/wild-emus-dream.md deleted file mode 100644 index 19e5a4626b..0000000000 --- a/.changeset/wild-emus-dream.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Allow control over maxTokens for thinking models diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a4a30cbd..0e5223231a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Roo Code Changelog +## 3.7.6 + +### Patch Changes + +- Handle really long text in the ChatRow similar to TaskHeader +- Support multiple files in drag-and-drop +- Truncate search_file output to avoid crashing the extension +- Better OpenRouter error handling +- Allow control over maxTokens for thinking models + ## [3.7.5] - Fix context window truncation math (see [#1173](https://github.com/RooVetGit/Roo-Code/issues/1173)) diff --git a/package-lock.json b/package-lock.json index a6c75bd69b..808e2f2f10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roo-cline", - "version": "3.7.5", + "version": "3.7.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "3.7.5", + "version": "3.7.6", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", diff --git a/package.json b/package.json index 40bb6a545d..463e9d597a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Roo Code (prev. Roo Cline)", "description": "A whole dev team of AI agents in your editor.", "publisher": "RooVeterinaryInc", - "version": "3.7.5", + "version": "3.7.6", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", From 4f578dc8262e03a2a665abcd6784610cc092cdb2 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 26 Feb 2025 18:47:22 -0500 Subject: [PATCH 06/28] Update CHANGELOG.md --- CHANGELOG.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5223231a..13b0695335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,12 @@ # Roo Code Changelog -## 3.7.6 +## [3.7.6] -### Patch Changes - -- Handle really long text in the ChatRow similar to TaskHeader +- Handle really long text better in the in the ChatRow similar to TaskHeader (thanks @joemanley201!) - Support multiple files in drag-and-drop - Truncate search_file output to avoid crashing the extension -- Better OpenRouter error handling -- Allow control over maxTokens for thinking models +- Better OpenRouter error handling (no more "Provider Error") +- Add slider to control max output tokens for thinking models ## [3.7.5] From 5c5bf8502094fb87397eebadda89acd3512dcf84 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 26 Feb 2025 21:36:19 -0500 Subject: [PATCH 07/28] Stop removing commas from terminal output --- .changeset/sour-parents-hug.md | 5 +++++ src/integrations/terminal/TerminalProcess.ts | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 .changeset/sour-parents-hug.md diff --git a/.changeset/sour-parents-hug.md b/.changeset/sour-parents-hug.md new file mode 100644 index 0000000000..a24286b6bb --- /dev/null +++ b/.changeset/sour-parents-hug.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Stop removing commas from terminal output diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 5597350db3..4e85c10575 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -110,9 +110,6 @@ export class TerminalProcess extends EventEmitter { data = lines.join("\n") } - // FIXME: right now it seems that data chunks returned to us from the shell integration stream contains random commas, which from what I can tell is not the expected behavior. There has to be a better solution here than just removing all commas. - data = data.replace(/,/g, "") - // 2. Set isHot depending on the command // Set to hot to stall API requests until terminal is cool again this.isHot = true From 4806ab5420048af6526348e5b128dd4724c9fcc8 Mon Sep 17 00:00:00 2001 From: dleffel Date: Wed, 26 Feb 2025 21:34:56 -0800 Subject: [PATCH 08/28] Fix missing tooltips in several components. --- .../src/components/chat/Announcement.tsx | 1 + .../src/components/chat/ChatTextArea.tsx | 5 +++ webview-ui/src/components/chat/ChatView.tsx | 33 ++++++++++++++++++- webview-ui/src/components/chat/TaskHeader.tsx | 13 ++++++-- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index a2e96606ef..93d0c9d750 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -25,6 +25,7 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => { diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index be2b2a9798..dcbe085147 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -798,6 +798,7 @@ const ChatTextArea = forwardRef( { const value = e.target.value if (value === "settings-action") { @@ -915,6 +917,7 @@ const ChatTextArea = forwardRef( role="button" aria-label="enhance prompt" data-testid="enhance-prompt-button" + title="Enhance prompt with additional context" className={`input-icon-button ${ textAreaDisabled ? "disabled" : "" } codicon codicon-sparkle`} @@ -927,11 +930,13 @@ const ChatTextArea = forwardRef( className={`input-icon-button ${ shouldDisableImages ? "disabled" : "" } codicon codicon-device-camera`} + title="Add images to message" onClick={() => !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} /> !textAreaDisabled && onSend()} style={{ fontSize: 15 }} /> diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 98369cf095..fcd1ba9a3b 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1077,7 +1077,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie onClick={() => { scrollToBottomSmooth() disableAutoScrollRef.current = false - }}> + }} + title="Scroll to bottom of chat">
@@ -1101,6 +1102,25 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie flex: secondaryButtonText ? 1 : 2, marginRight: secondaryButtonText ? "6px" : "0", }} + title={ + primaryButtonText === "Retry" + ? "Try the operation again" + : primaryButtonText === "Save" + ? "Save the file changes" + : primaryButtonText === "Approve" + ? "Approve this action" + : primaryButtonText === "Run Command" + ? "Execute this command" + : primaryButtonText === "Start New Task" + ? "Begin a new task" + : primaryButtonText === "Resume Task" + ? "Continue the current task" + : primaryButtonText === "Proceed Anyways" + ? "Continue despite warnings" + : primaryButtonText === "Proceed While Running" + ? "Continue while command executes" + : undefined + } onClick={(e) => handlePrimaryButtonClick(inputValue, selectedImages)}> {primaryButtonText} @@ -1113,6 +1133,17 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie flex: isStreaming ? 2 : 1, marginLeft: isStreaming ? 0 : "6px", }} + title={ + isStreaming + ? "Cancel the current operation" + : secondaryButtonText === "Start New Task" + ? "Begin a new task" + : secondaryButtonText === "Reject" + ? "Reject this action" + : secondaryButtonText === "Terminate" + ? "End the current task" + : undefined + } onClick={(e) => handleSecondaryButtonClick(inputValue, selectedImages)}> {isStreaming ? "Cancel" : secondaryButtonText} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 341855f796..fb7db6f617 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -180,7 +180,11 @@ const TaskHeader: React.FC = ({ ${totalCost?.toFixed(4)}
)} - + @@ -348,13 +352,18 @@ export const highlightMentions = (text?: string, withShadow = true) => { const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
- {!!item?.size && item.size > 0 && (
) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index d3e65a99ea..51ef4fe81d 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -52,7 +52,7 @@ const SettingsView = forwardRef(({ onDone }, alwaysAllowWrite, alwaysApproveResubmit, browserViewportSize, - checkpointsEnabled, + enableCheckpoints, diffEnabled, experiments, fuzzyMatchThreshold, @@ -143,7 +143,7 @@ const SettingsView = forwardRef(({ onDone }, vscode.postMessage({ type: "soundEnabled", bool: soundEnabled }) vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) - vscode.postMessage({ type: "checkpointsEnabled", bool: checkpointsEnabled }) + vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints }) vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize }) vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs }) @@ -706,6 +706,25 @@ const SettingsView = forwardRef(({ onDone },

+
+ { + setCachedStateField("enableCheckpoints", e.target.checked) + }}> + Enable automatic checkpoints + +

+ When enabled, Roo will automatically create checkpoints during task execution, making it + easy to review changes or revert to earlier states. +

+
+
(({ onDone },
)} -
-
- ⚠️ - { - setCachedStateField("checkpointsEnabled", e.target.checked) - }}> - Enable experimental checkpoints - -
-

- When enabled, Roo will save a checkpoint whenever a file in the workspace is modified, - added or deleted, letting you easily revert to a previous state. -

-
- {Object.entries(experimentConfigsMap) .filter((config) => config[0] !== "DIFF_STRATEGY") .map((config) => ( diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ae5c5b9539..3dfc87de75 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -32,7 +32,7 @@ export interface ExtensionStateContextType extends ExtensionState { setSoundEnabled: (value: boolean) => void setSoundVolume: (value: number) => void setDiffEnabled: (value: boolean) => void - setCheckpointsEnabled: (value: boolean) => void + setEnableCheckpoints: (value: boolean) => void setBrowserViewportSize: (value: string) => void setFuzzyMatchThreshold: (value: number) => void preferredLanguage: string @@ -79,7 +79,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode soundEnabled: false, soundVolume: 0.5, diffEnabled: false, - checkpointsEnabled: false, + enableCheckpoints: true, fuzzyMatchThreshold: 1.0, preferredLanguage: "English", writeDelayMs: 1000, @@ -219,7 +219,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })), setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })), setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })), - setCheckpointsEnabled: (value) => setState((prevState) => ({ ...prevState, checkpointsEnabled: value })), + setEnableCheckpoints: (value) => setState((prevState) => ({ ...prevState, enableCheckpoints: value })), setBrowserViewportSize: (value: string) => setState((prevState) => ({ ...prevState, browserViewportSize: value })), setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })), From ea38d9ebbac80f4170f74b4c7d978e588b132d2d Mon Sep 17 00:00:00 2001 From: Aitor Oses Date: Thu, 27 Feb 2025 09:04:54 +0100 Subject: [PATCH 10/28] Enable prompt caching for Claude Sonnet 3.7 Vertex AI model --- src/shared/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/api.ts b/src/shared/api.ts index 5cda333031..cd6aead1a5 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -441,7 +441,7 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 3.0, outputPrice: 15.0, }, From 0b583ed15ee80acdd853c6e994c1694f3d4f0cca Mon Sep 17 00:00:00 2001 From: cte Date: Thu, 27 Feb 2025 02:34:14 -0800 Subject: [PATCH 11/28] Fix AnthropicHandler#completePrompt --- src/api/providers/anthropic.ts | 98 +++++++++++++++++----------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 8c5a1795b1..eca81eab2e 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -30,29 +30,7 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler { async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { let stream: AnthropicStream const cacheControl: CacheControlEphemeral = { type: "ephemeral" } - let { id: modelId, info: modelInfo } = this.getModel() - const maxTokens = this.options.modelMaxTokens || modelInfo.maxTokens || 8192 - let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE - let thinking: BetaThinkingConfigParam | undefined = undefined - - // 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" - - // Clamp the thinking budget to be at most 80% of max tokens and at - // least 1024 tokens. - const maxBudgetTokens = Math.floor(maxTokens * 0.8) - const budgetTokens = Math.max( - Math.min(this.options.anthropicThinking ?? maxBudgetTokens, maxBudgetTokens), - 1024, - ) - - thinking = { type: "enabled", budget_tokens: budgetTokens } - temperature = 1.0 - } + let { id: modelId, temperature, maxTokens, thinking } = this.getModel() switch (modelId) { case "claude-3-7-sonnet-20250219": @@ -202,40 +180,62 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler { } } - getModel(): { id: AnthropicModelId; info: ModelInfo } { + getModel() { const modelId = this.options.apiModelId + let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE + let thinking: BetaThinkingConfigParam | undefined = undefined if (modelId && modelId in anthropicModels) { - const id = modelId as AnthropicModelId - return { id, info: anthropicModels[id] } + let id = modelId as AnthropicModelId + const info: ModelInfo = anthropicModels[id] + + // 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. + if (id === "claude-3-7-sonnet-20250219:thinking") { + id = "claude-3-7-sonnet-20250219" + } + + const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192 + + if (info.thinking) { + // Anthropic "Thinking" models require a temperature of 1.0. + temperature = 1.0 + + // Clamp the thinking budget to be at most 80% of max tokens and at + // least 1024 tokens. + const maxBudgetTokens = Math.floor(maxTokens * 0.8) + const budgetTokens = Math.max( + Math.min(this.options.anthropicThinking ?? maxBudgetTokens, maxBudgetTokens), + 1024, + ) + + thinking = { type: "enabled", budget_tokens: budgetTokens } + } + + return { id, info, temperature, maxTokens, thinking } } - return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] } + const id = anthropicDefaultModelId + const info: ModelInfo = anthropicModels[id] + const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192 + + return { id, info, temperature, maxTokens, thinking } } - async completePrompt(prompt: string): Promise { - try { - const response = await this.client.messages.create({ - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens || 8192, - temperature: this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE, - messages: [{ role: "user", content: prompt }], - stream: false, - }) + async completePrompt(prompt: string) { + let { id: modelId, temperature, maxTokens, thinking } = this.getModel() - const content = response.content[0] + const message = await this.client.messages.create({ + model: modelId, + max_tokens: maxTokens, + temperature, + thinking, + messages: [{ role: "user", content: prompt }], + stream: false, + }) - if (content.type === "text") { - return content.text - } - - return "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`Anthropic completion error: ${error.message}`) - } - - throw error - } + const content = message.content.find(({ type }) => type === "text") + return content?.type === "text" ? content.text : "" } } From d66b5d2db62f0a6cb8650b8f465d14cf77bbcd36 Mon Sep 17 00:00:00 2001 From: cte Date: Thu, 27 Feb 2025 02:40:39 -0800 Subject: [PATCH 12/28] Fix tests --- src/api/providers/__tests__/anthropic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/anthropic.test.ts b/src/api/providers/__tests__/anthropic.test.ts index ff7bdb4054..82e098f65f 100644 --- a/src/api/providers/__tests__/anthropic.test.ts +++ b/src/api/providers/__tests__/anthropic.test.ts @@ -153,7 +153,7 @@ describe("AnthropicHandler", () => { }) it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) + mockCreate.mockRejectedValueOnce(new Error("Anthropic completion error: API Error")) await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Anthropic completion error: API Error") }) From 210afc681e799ade14fa2886e07cce0cb6aa2496 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 27 Feb 2025 09:44:50 -0500 Subject: [PATCH 13/28] v3.7.7 --- .changeset/gorgeous-feet-dress.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gorgeous-feet-dress.md diff --git a/.changeset/gorgeous-feet-dress.md b/.changeset/gorgeous-feet-dress.md new file mode 100644 index 0000000000..fe2183052d --- /dev/null +++ b/.changeset/gorgeous-feet-dress.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +v3.7.7 From dc83617b4d2da06b830e848476a1c5d9179a361a Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 27 Feb 2025 09:51:05 -0500 Subject: [PATCH 14/28] Revert "Stop removing commas from terminal output" --- .changeset/sour-parents-hug.md | 5 ----- src/integrations/terminal/TerminalProcess.ts | 3 +++ 2 files changed, 3 insertions(+), 5 deletions(-) delete mode 100644 .changeset/sour-parents-hug.md diff --git a/.changeset/sour-parents-hug.md b/.changeset/sour-parents-hug.md deleted file mode 100644 index a24286b6bb..0000000000 --- a/.changeset/sour-parents-hug.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Stop removing commas from terminal output diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 4e85c10575..5597350db3 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -110,6 +110,9 @@ export class TerminalProcess extends EventEmitter { data = lines.join("\n") } + // FIXME: right now it seems that data chunks returned to us from the shell integration stream contains random commas, which from what I can tell is not the expected behavior. There has to be a better solution here than just removing all commas. + data = data.replace(/,/g, "") + // 2. Set isHot depending on the command // Set to hot to stall API requests until terminal is cool again this.isHot = true From 8612ab574be39f863329c1b93b32d257bd67450f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 27 Feb 2025 15:56:03 +0000 Subject: [PATCH 15/28] changeset version bump --- .changeset/eighty-cheetahs-fetch.md | 5 ----- .changeset/gorgeous-feet-dress.md | 5 ----- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 .changeset/eighty-cheetahs-fetch.md delete mode 100644 .changeset/gorgeous-feet-dress.md diff --git a/.changeset/eighty-cheetahs-fetch.md b/.changeset/eighty-cheetahs-fetch.md deleted file mode 100644 index ca103880c8..0000000000 --- a/.changeset/eighty-cheetahs-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Graduate checkpoints out of beta diff --git a/.changeset/gorgeous-feet-dress.md b/.changeset/gorgeous-feet-dress.md deleted file mode 100644 index fe2183052d..0000000000 --- a/.changeset/gorgeous-feet-dress.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -v3.7.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 13b0695335..ff5d8d6aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Roo Code Changelog +## 3.7.7 + +### Patch Changes + +- Graduate checkpoints out of beta +- v3.7.7 + ## [3.7.6] - Handle really long text better in the in the ChatRow similar to TaskHeader (thanks @joemanley201!) diff --git a/package-lock.json b/package-lock.json index 808e2f2f10..c1f748983f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roo-cline", - "version": "3.7.6", + "version": "3.7.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "3.7.6", + "version": "3.7.7", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", diff --git a/package.json b/package.json index 463e9d597a..8441488bac 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Roo Code (prev. Roo Cline)", "description": "A whole dev team of AI agents in your editor.", "publisher": "RooVeterinaryInc", - "version": "3.7.6", + "version": "3.7.7", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", From 4786815fe8bccf47af9202ff8cb99c6f1ef8ad16 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 27 Feb 2025 11:02:13 -0500 Subject: [PATCH 16/28] Update CHANGELOG.md --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff5d8d6aaf..d0cf8f79c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,10 @@ # Roo Code Changelog -## 3.7.7 - -### Patch Changes +## [3.7.7] - Graduate checkpoints out of beta -- v3.7.7 +- Fix enhance prompt button when using Thinking Sonnet +- Add tooltips to make what buttons do more obvious ## [3.7.6] From eec1769b6b5883d3179e2d1a370ed01b83078286 Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti Date: Thu, 27 Feb 2025 18:56:28 +0000 Subject: [PATCH 17/28] Added cache costs for Claude Sonnet 3.7 via Vertex AI --- src/shared/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/api.ts b/src/shared/api.ts index e7e4c54db6..d2b4ed728f 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -444,6 +444,8 @@ export const vertexModels = { supportsPromptCache: false, inputPrice: 3.0, outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, }, "claude-3-5-sonnet-v2@20241022": { maxTokens: 8192, From 1f0211ee6418752201b7d9b34ffb12608ba4a54d Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 23 Feb 2025 20:52:10 -0600 Subject: [PATCH 18/28] Allow users to set custom system prompts --- src/__mocks__/fs/promises.ts | 1 - src/__mocks__/jest.setup.ts | 30 +++ .../__tests__/custom-system-prompt.test.ts | 172 ++++++++++++++++++ .../prompts/sections/custom-system-prompt.ts | 60 ++++++ src/core/prompts/system.ts | 15 ++ .../src/components/prompts/PromptsView.tsx | 40 ++++ 6 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/core/prompts/__tests__/custom-system-prompt.test.ts create mode 100644 src/core/prompts/sections/custom-system-prompt.ts diff --git a/src/__mocks__/fs/promises.ts b/src/__mocks__/fs/promises.ts index d5f076247a..e496a7fa51 100644 --- a/src/__mocks__/fs/promises.ts +++ b/src/__mocks__/fs/promises.ts @@ -140,7 +140,6 @@ const mockFs = { currentPath += "/" + parts[parts.length - 1] mockDirectories.add(currentPath) return Promise.resolve() - return Promise.resolve() }), access: jest.fn().mockImplementation(async (path: string) => { diff --git a/src/__mocks__/jest.setup.ts b/src/__mocks__/jest.setup.ts index 6bd00e9567..836279bfe4 100644 --- a/src/__mocks__/jest.setup.ts +++ b/src/__mocks__/jest.setup.ts @@ -15,3 +15,33 @@ jest.mock("../utils/logging", () => ({ }), }, })) + +// Add toPosix method to String prototype for all tests, mimicking src/utils/path.ts +// This is needed because the production code expects strings to have this method +// Note: In production, this is added via import in the entry point (extension.ts) +export {} + +declare global { + interface String { + toPosix(): string + } +} + +// Implementation that matches src/utils/path.ts +function toPosixPath(p: string) { + // Extended-Length Paths in Windows start with "\\?\" to allow longer paths + // and bypass usual parsing. If detected, we return the path unmodified. + const isExtendedLengthPath = p.startsWith("\\\\?\\") + + if (isExtendedLengthPath) { + return p + } + + return p.replace(/\\/g, "/") +} + +if (!String.prototype.toPosix) { + String.prototype.toPosix = function (this: string): string { + return toPosixPath(this) + } +} diff --git a/src/core/prompts/__tests__/custom-system-prompt.test.ts b/src/core/prompts/__tests__/custom-system-prompt.test.ts new file mode 100644 index 0000000000..7594c13e6d --- /dev/null +++ b/src/core/prompts/__tests__/custom-system-prompt.test.ts @@ -0,0 +1,172 @@ +import { SYSTEM_PROMPT } from "../system" +import { defaultModeSlug, modes } from "../../../shared/modes" +import * as vscode from "vscode" +import * as fs from "fs/promises" + +// Mock the fs/promises module +jest.mock("fs/promises", () => ({ + readFile: jest.fn(), + mkdir: jest.fn().mockResolvedValue(undefined), + access: jest.fn().mockResolvedValue(undefined), +})) + +// Get the mocked fs module +const mockedFs = fs as jest.Mocked + +// Mock the fileExistsAtPath function +jest.mock("../../../utils/fs", () => ({ + fileExistsAtPath: jest.fn().mockResolvedValue(true), + createDirectoriesForFile: jest.fn().mockResolvedValue([]), +})) + +// Create a mock ExtensionContext with relative paths instead of absolute paths +const mockContext = { + extensionPath: "mock/extension/path", + globalStoragePath: "mock/storage/path", + storagePath: "mock/storage/path", + logPath: "mock/log/path", + subscriptions: [], + workspaceState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + setKeysForSync: () => {}, + }, + extensionUri: { fsPath: "mock/extension/path" }, + globalStorageUri: { fsPath: "mock/settings/path" }, + asAbsolutePath: (relativePath: string) => `mock/extension/path/${relativePath}`, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, +} as unknown as vscode.ExtensionContext + +describe("File-Based Custom System Prompt", () => { + const experiments = {} + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks() + + // Default behavior: file doesn't exist + mockedFs.readFile.mockRejectedValue({ code: "ENOENT" }) + }) + + it("should use default generation when no file-based system prompt is found", async () => { + const customModePrompts = { + [defaultModeSlug]: { + roleDefinition: "Test role definition", + }, + } + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + customModePrompts, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain default sections + expect(prompt).toContain("TOOL USE") + expect(prompt).toContain("CAPABILITIES") + expect(prompt).toContain("MODES") + expect(prompt).toContain("Test role definition") + }) + + it("should use file-based custom system prompt when available", async () => { + // Mock the readFile to return content from a file + const fileCustomSystemPrompt = "Custom system prompt from file" + // When called with utf-8 encoding, return a string + mockedFs.readFile.mockImplementation((filePath, options) => { + if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") { + return Promise.resolve(fileCustomSystemPrompt) + } + return Promise.reject({ code: "ENOENT" }) + }) + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + undefined, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain role definition and file-based system prompt + expect(prompt).toContain(modes[0].roleDefinition) + expect(prompt).toContain(fileCustomSystemPrompt) + + // Should not contain any of the default sections + expect(prompt).not.toContain("TOOL USE") + expect(prompt).not.toContain("CAPABILITIES") + expect(prompt).not.toContain("MODES") + }) + + it("should combine file-based system prompt with role definition and custom instructions", async () => { + // Mock the readFile to return content from a file + const fileCustomSystemPrompt = "Custom system prompt from file" + mockedFs.readFile.mockImplementation((filePath, options) => { + if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") { + return Promise.resolve(fileCustomSystemPrompt) + } + return Promise.reject({ code: "ENOENT" }) + }) + + // Define custom role definition + const customRoleDefinition = "Custom role definition" + const customModePrompts = { + [defaultModeSlug]: { + roleDefinition: customRoleDefinition, + }, + } + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + customModePrompts, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain custom role definition and file-based system prompt + expect(prompt).toContain(customRoleDefinition) + expect(prompt).toContain(fileCustomSystemPrompt) + + // Should not contain any of the default sections + expect(prompt).not.toContain("TOOL USE") + expect(prompt).not.toContain("CAPABILITIES") + expect(prompt).not.toContain("MODES") + }) +}) diff --git a/src/core/prompts/sections/custom-system-prompt.ts b/src/core/prompts/sections/custom-system-prompt.ts new file mode 100644 index 0000000000..eca2b98b8d --- /dev/null +++ b/src/core/prompts/sections/custom-system-prompt.ts @@ -0,0 +1,60 @@ +import fs from "fs/promises" +import path from "path" +import { Mode } from "../../../shared/modes" +import { fileExistsAtPath } from "../../../utils/fs" + +/** + * Safely reads a file, returning an empty string if the file doesn't exist + */ +async function safeReadFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + // When reading with "utf-8" encoding, content should be a string + return content.trim() + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException).code + if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) { + throw err + } + return "" + } +} + +/** + * Get the path to a system prompt file for a specific mode + */ +export function getSystemPromptFilePath(cwd: string, mode: Mode): string { + return path.join(cwd, ".roo", `system-prompt-${mode}`) +} + +/** + * Loads custom system prompt from a file at .roo/system-prompt-[mode slug] + * If the file doesn't exist, returns an empty string + */ +export async function loadSystemPromptFile(cwd: string, mode: Mode): Promise { + const filePath = getSystemPromptFilePath(cwd, mode) + return safeReadFile(filePath) +} + +/** + * Ensures the .roo directory exists, creating it if necessary + */ +export async function ensureRooDirectory(cwd: string): Promise { + const rooDir = path.join(cwd, ".roo") + + // Check if directory already exists + if (await fileExistsAtPath(rooDir)) { + return + } + + // Create the directory + try { + await fs.mkdir(rooDir, { recursive: true }) + } catch (err) { + // If directory already exists (race condition), ignore the error + const errorCode = (err as NodeJS.ErrnoException).code + if (errorCode !== "EEXIST") { + throw err + } + } +} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 91bbd07387..90791f6358 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -23,6 +23,7 @@ import { getModesSection, addCustomInstructions, } from "./sections" +import { loadSystemPromptFile } from "./sections/custom-system-prompt" import fs from "fs/promises" import path from "path" @@ -119,11 +120,25 @@ export const SYSTEM_PROMPT = async ( return undefined } + // Try to load custom system prompt from file + const fileCustomSystemPrompt = await loadSystemPromptFile(cwd, mode) + // Check if it's a custom mode const promptComponent = getPromptComponent(customModePrompts?.[mode]) + // Get full mode config from custom modes or fall back to built-in modes const currentMode = getModeBySlug(mode, customModes) || modes.find((m) => m.slug === mode) || modes[0] + // If a file-based custom system prompt exists, use it + if (fileCustomSystemPrompt) { + const roleDefinition = promptComponent?.roleDefinition || currentMode.roleDefinition + return `${roleDefinition} + +${fileCustomSystemPrompt} + +${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}` + } + // If diff is disabled, don't pass the diffStrategy const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 061fa789de..2bfafeff5c 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -88,6 +88,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { const [showConfigMenu, setShowConfigMenu] = useState(false) const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false) const [activeSupportTab, setActiveSupportTab] = useState("ENHANCE") + const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false) // Direct update functions const updateAgentPrompt = useCallback( @@ -971,6 +972,45 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
+ + {/* Custom System Prompt Disclosure */} +
+ + + {isSystemPromptDisclosureOpen && ( +
+ You can completely replace the system prompt for this mode (aside from the role + definition and custom instructions) by creating a file at{" "} + { + const currentMode = getCurrentMode() + if (!currentMode) return + + // Open or create an empty file + vscode.postMessage({ + type: "openFile", + text: `./.roo/system-prompt-${currentMode.slug}`, + values: { + create: true, + content: "", + }, + }) + }}> + .roo/system-prompt-{getCurrentMode()?.slug || "code"} + {" "} + in your workspace. This is a very advanced feature that bypasses built-in safeguards and + consistency checks (especially around tool usage), so be careful! +
+ )} +
Date: Thu, 27 Feb 2025 15:18:54 -0500 Subject: [PATCH 19/28] Add gpt-4.5-preview --- .changeset/flat-avocados-carry.md | 5 +++++ src/api/providers/__tests__/openai-native.test.ts | 2 +- src/shared/api.ts | 10 +++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/flat-avocados-carry.md diff --git a/.changeset/flat-avocados-carry.md b/.changeset/flat-avocados-carry.md new file mode 100644 index 0000000000..f0128f21e0 --- /dev/null +++ b/.changeset/flat-avocados-carry.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add gpt-4.5-preview diff --git a/src/api/providers/__tests__/openai-native.test.ts b/src/api/providers/__tests__/openai-native.test.ts index d6a855849c..eda744c335 100644 --- a/src/api/providers/__tests__/openai-native.test.ts +++ b/src/api/providers/__tests__/openai-native.test.ts @@ -357,7 +357,7 @@ describe("OpenAiNativeHandler", () => { const modelInfo = handler.getModel() expect(modelInfo.id).toBe(mockOptions.apiModelId) expect(modelInfo.info).toBeDefined() - expect(modelInfo.info.maxTokens).toBe(4096) + expect(modelInfo.info.maxTokens).toBe(16384) expect(modelInfo.info.contextWindow).toBe(128_000) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index 442282d587..47b023ce6f 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -678,8 +678,16 @@ export const openAiNativeModels = { inputPrice: 1.1, outputPrice: 4.4, }, + "gpt-4.5-preview": { + maxTokens: 16_384, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 75, + outputPrice: 150, + }, "gpt-4o": { - maxTokens: 4_096, + maxTokens: 16_384, contextWindow: 128_000, supportsImages: true, supportsPromptCache: false, From 820ebc97c5251e04194b014bd60ab51e08de1481 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 27 Feb 2025 20:41:23 +0000 Subject: [PATCH 20/28] changeset version bump --- .changeset/flat-avocados-carry.md | 5 ----- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/flat-avocados-carry.md diff --git a/.changeset/flat-avocados-carry.md b/.changeset/flat-avocados-carry.md deleted file mode 100644 index f0128f21e0..0000000000 --- a/.changeset/flat-avocados-carry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Add gpt-4.5-preview diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cf8f79c3..e3aa95d448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Roo Code Changelog +## 3.7.8 + +### Patch Changes + +- Add gpt-4.5-preview + ## [3.7.7] - Graduate checkpoints out of beta diff --git a/package-lock.json b/package-lock.json index c1f748983f..e7d7718b75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roo-cline", - "version": "3.7.7", + "version": "3.7.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "3.7.7", + "version": "3.7.8", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", diff --git a/package.json b/package.json index 8441488bac..a4a2298a48 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Roo Code (prev. Roo Cline)", "description": "A whole dev team of AI agents in your editor.", "publisher": "RooVeterinaryInc", - "version": "3.7.7", + "version": "3.7.8", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", From ca7d746990ace8208b5f444ae6f16ea0f25525a1 Mon Sep 17 00:00:00 2001 From: R00-B0T Date: Thu, 27 Feb 2025 20:41:50 +0000 Subject: [PATCH 21/28] Updating CHANGELOG.md format --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3aa95d448..fe8156abf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,6 @@ # Roo Code Changelog -## 3.7.8 - -### Patch Changes +## [3.7.8] - Add gpt-4.5-preview From 75e7ef728d0c8512a2f8835a3139bba194f8b327 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 27 Feb 2025 16:09:28 -0500 Subject: [PATCH 22/28] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe8156abf7..9622ce0c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## [3.7.8] +- Add Vertex AI prompt caching support for Claude models (thanks @aitoroses and @lupuletic!) - Add gpt-4.5-preview +- Add an advanced feature to customize the system prompt ## [3.7.7] From 3514f6506b5b0e24919bad29e65b8eba11afced5 Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti Date: Thu, 27 Feb 2025 21:56:56 +0000 Subject: [PATCH 23/28] Added support for Claude Sonnet 3.7 thinking via Vertex AI --- package-lock.json | 10 +- package.json | 2 +- src/api/providers/vertex.ts | 110 +++++++++++++++--- src/core/webview/ClineProvider.ts | 5 + src/shared/api.ts | 14 +++ src/shared/globalState.ts | 2 + .../src/components/settings/ApiOptions.tsx | 3 + .../components/settings/ThinkingBudget.tsx | 30 +++-- 8 files changed, 143 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index c1f748983f..547f20a930 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", - "@anthropic-ai/vertex-sdk": "^0.4.1", + "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.706.0", "@google/generative-ai": "^0.18.0", "@mistralai/mistralai": "^1.3.6", @@ -150,11 +150,11 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/@anthropic-ai/vertex-sdk": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.4.3.tgz", - "integrity": "sha512-2Uef0C5P2Hx+T88RnUSRA3u4aZqmqnrRSOb2N64ozgKPiSUPTM5JlggAq2b32yWMj5d3MLYa6spJXKMmHXOcoA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.7.0.tgz", + "integrity": "sha512-zNm3hUXgYmYDTyveIxOyxbcnh5VXFkrLo4bSnG6LAfGzW7k3k2iCNDSVKtR9qZrK2BCid7JtVu7jsEKaZ/9dSw==", "dependencies": { - "@anthropic-ai/sdk": ">=0.14 <1", + "@anthropic-ai/sdk": ">=0.35 <1", "google-auth-library": "^9.4.2" } }, diff --git a/package.json b/package.json index 8441488bac..35db01621a 100644 --- a/package.json +++ b/package.json @@ -305,7 +305,7 @@ "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", - "@anthropic-ai/vertex-sdk": "^0.4.1", + "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.706.0", "@google/generative-ai": "^0.18.0", "@mistralai/mistralai": "^1.3.6", diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 70562766c3..69fb7d26f7 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming" import { ApiHandler, SingleCompletionHandler } from "../" +import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta" import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api" import { ApiStream } from "../transform/stream" @@ -70,15 +71,25 @@ interface VertexMessageStreamEvent { usage?: { output_tokens: number } - content_block?: { - type: "text" - text: string - } + content_block?: + | { + type: "text" + text: string + } + | { + type: "thinking" + thinking: string + } index?: number - delta?: { - type: "text_delta" - text: string - } + delta?: + | { + type: "text_delta" + text: string + } + | { + type: "thinking_delta" + thinking: string + } } // https://docs.anthropic.com/en/api/claude-on-vertex-ai @@ -145,6 +156,7 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { const model = this.getModel() + let { id, info, temperature, maxTokens, thinking } = model const useCache = model.info.supportsPromptCache // Find indices of user messages that we want to cache @@ -158,9 +170,10 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { // Create the stream with appropriate caching configuration const params = { - model: model.id, - max_tokens: model.info.maxTokens || 8192, - temperature: this.options.modelTemperature ?? 0, + model: id, + max_tokens: maxTokens, + temperature, + thinking, // Cache the system prompt if caching is enabled system: useCache ? [ @@ -220,6 +233,19 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { } break } + case "thinking": { + if (chunk.index! > 0) { + yield { + type: "reasoning", + text: "\n", + } + } + yield { + type: "reasoning", + text: (chunk.content_block as any).thinking, + } + break + } } break } @@ -232,6 +258,13 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { } break } + case "thinking_delta": { + yield { + type: "reasoning", + text: (chunk.delta as any).thinking, + } + break + } } break } @@ -239,24 +272,63 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { } } - getModel(): { id: VertexModelId; info: ModelInfo } { + getModel(): { + id: VertexModelId + info: ModelInfo + temperature: number + maxTokens: number + thinking?: BetaThinkingConfigParam + } { const modelId = this.options.apiModelId + let temperature = this.options.modelTemperature ?? 0 + let thinking: BetaThinkingConfigParam | undefined = undefined + if (modelId && modelId in vertexModels) { const id = modelId as VertexModelId - return { id, info: vertexModels[id] } + const info: ModelInfo = vertexModels[id] + + // The `:thinking` variant is a virtual identifier for thinking-enabled models + // Similar to how it's handled in the Anthropic provider + let actualId = id + if (id.endsWith(":thinking")) { + actualId = id.replace(":thinking", "") as VertexModelId + } + + const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192 + + if (info.thinking) { + temperature = 1.0 // Thinking requires temperature 1.0 + const maxBudgetTokens = Math.floor(maxTokens * 0.8) + const budgetTokens = Math.max( + Math.min( + this.options.vertexThinking ?? this.options.anthropicThinking ?? maxBudgetTokens, + maxBudgetTokens, + ), + 1024, + ) + thinking = { type: "enabled", budget_tokens: budgetTokens } + } + + return { id: actualId, info, temperature, maxTokens, thinking } } - return { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] } + + const id = vertexDefaultModelId + const info = vertexModels[id] + const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192 + + return { id, info, temperature, maxTokens, thinking } } async completePrompt(prompt: string): Promise { try { - const model = this.getModel() - const useCache = model.info.supportsPromptCache + let { id, info, temperature, maxTokens, thinking } = this.getModel() + const useCache = info.supportsPromptCache const params = { - model: model.id, - max_tokens: model.info.maxTokens || 8192, - temperature: this.options.modelTemperature ?? 0, + model: id, + max_tokens: maxTokens, + temperature, + thinking, system: "", // No system prompt needed for single completions messages: [ { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 633c7d7293..5417e54ff7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1652,6 +1652,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioBaseUrl, anthropicBaseUrl, anthropicThinking, + vertexThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, @@ -1701,6 +1702,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl), this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl), this.updateGlobalState("anthropicThinking", anthropicThinking), + this.updateGlobalState("vertexThinking", vertexThinking), this.storeSecret("geminiApiKey", geminiApiKey), this.storeSecret("openAiNativeApiKey", openAiNativeApiKey), this.storeSecret("deepSeekApiKey", deepSeekApiKey), @@ -2158,6 +2160,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioBaseUrl, anthropicBaseUrl, anthropicThinking, + vertexThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, @@ -2242,6 +2245,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("lmStudioBaseUrl") as Promise, this.getGlobalState("anthropicBaseUrl") as Promise, this.getGlobalState("anthropicThinking") as Promise, + this.getGlobalState("vertexThinking") as Promise, this.getSecret("geminiApiKey") as Promise, this.getSecret("openAiNativeApiKey") as Promise, this.getSecret("deepSeekApiKey") as Promise, @@ -2343,6 +2347,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioBaseUrl, anthropicBaseUrl, anthropicThinking, + vertexThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, diff --git a/src/shared/api.ts b/src/shared/api.ts index 442282d587..f048761d0f 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -41,6 +41,7 @@ export interface ApiHandlerOptions { awsUseProfile?: boolean vertexProjectId?: string vertexRegion?: string + vertexThinking?: number openAiBaseUrl?: string openAiApiKey?: string openAiModelId?: string @@ -436,6 +437,18 @@ export const openRouterDefaultModelInfo: ModelInfo = { export type VertexModelId = keyof typeof vertexModels export const vertexDefaultModelId: VertexModelId = "claude-3-7-sonnet@20250219" export const vertexModels = { + "claude-3-7-sonnet@20250219:thinking": { + maxTokens: 64000, + contextWindow: 200_000, + supportsImages: true, + supportsComputerUse: true, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + thinking: true, + }, "claude-3-7-sonnet@20250219": { maxTokens: 8192, contextWindow: 200_000, @@ -446,6 +459,7 @@ export const vertexModels = { outputPrice: 15.0, cacheWritesPrice: 3.75, cacheReadsPrice: 0.3, + thinking: false, }, "claude-3-5-sonnet-v2@20241022": { maxTokens: 8192, diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 0863b34db2..05b868a450 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -24,6 +24,7 @@ export type GlobalStateKey = | "awsUseProfile" | "vertexProjectId" | "vertexRegion" + | "vertexThinking" | "lastShownAnnouncementId" | "customInstructions" | "alwaysAllowReadOnly" @@ -43,6 +44,7 @@ export type GlobalStateKey = | "lmStudioBaseUrl" | "anthropicBaseUrl" | "anthropicThinking" + | "vertexThinking" | "azureApiVersion" | "openAiStreamingEnabled" | "openRouterModelId" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index c30035cef0..42ac5cdcb3 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -7,6 +7,7 @@ import * as vscodemodels from "vscode" import { ApiConfiguration, ModelInfo, + ApiProvider, anthropicDefaultModelId, anthropicModels, azureOpenAiDefaultApiVersion, @@ -1380,9 +1381,11 @@ const ApiOptions = ({ />
(field: K, value: ApiConfiguration[K]) => void modelInfo?: ModelInfo + provider?: ApiProvider } -export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => { +export const ThinkingBudget = ({ + apiConfiguration, + setApiConfigurationField, + modelInfo, + provider, +}: ThinkingBudgetProps) => { + const isVertexProvider = provider === "vertex" + const budgetField = isVertexProvider ? "vertexThinking" : "anthropicThinking" + const tokens = apiConfiguration?.modelMaxTokens || modelInfo?.maxTokens || 64_000 const tokensMin = 8192 const tokensMax = modelInfo?.maxTokens || 64_000 - const thinkingTokens = apiConfiguration?.anthropicThinking || 8192 + // Get the appropriate thinking tokens based on provider + const thinkingTokens = useMemo(() => { + const value = isVertexProvider ? apiConfiguration?.vertexThinking : apiConfiguration?.anthropicThinking + return value || Math.min(Math.floor(0.8 * tokens), 8192) + }, [apiConfiguration, isVertexProvider, tokens]) + const thinkingTokensMin = 1024 const thinkingTokensMax = Math.floor(0.8 * tokens) useEffect(() => { if (thinkingTokens > thinkingTokensMax) { - setApiConfigurationField("anthropicThinking", thinkingTokensMax) + setApiConfigurationField(budgetField, thinkingTokensMax) } - }, [thinkingTokens, thinkingTokensMax, setApiConfigurationField]) + }, [thinkingTokens, thinkingTokensMax, setApiConfigurationField, budgetField]) - if (!modelInfo || !modelInfo.thinking) { + if (!modelInfo?.thinking) { return null } @@ -52,7 +66,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod max={thinkingTokensMax} step={1024} value={[thinkingTokens]} - onValueChange={([value]) => setApiConfigurationField("anthropicThinking", value)} + onValueChange={([value]) => setApiConfigurationField(budgetField, value)} />
{thinkingTokens}
From 5eba1d53fbeef6f71f027d8317d9f99d120e8026 Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti Date: Thu, 27 Feb 2025 22:15:17 +0000 Subject: [PATCH 24/28] Added tests for Claude Sonnet Thinking --- src/api/providers/__tests__/vertex.test.ts | 250 ++++++++++++++++++ .../settings/__tests__/ApiOptions.test.tsx | 57 +++- .../__tests__/ThinkingBudget.test.tsx | 145 ++++++++++ 3 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx diff --git a/src/api/providers/__tests__/vertex.test.ts b/src/api/providers/__tests__/vertex.test.ts index 6e81fd771b..076f902ca2 100644 --- a/src/api/providers/__tests__/vertex.test.ts +++ b/src/api/providers/__tests__/vertex.test.ts @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" +import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta" import { VertexHandler } from "../vertex" import { ApiStreamChunk } from "../../transform/stream" @@ -431,6 +432,138 @@ describe("VertexHandler", () => { }) }) + describe("thinking functionality", () => { + const mockMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello", + }, + ] + + const systemPrompt = "You are a helpful assistant" + + it("should handle thinking content blocks and deltas", async () => { + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "thinking", + thinking: "Let me think about this...", + }, + }, + { + type: "content_block_delta", + delta: { + type: "thinking_delta", + thinking: " I need to consider all options.", + }, + }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "text", + text: "Here's my answer:", + }, + }, + ] + + // Setup async iterator for mock stream + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks: ApiStreamChunk[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify thinking content is processed correctly + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks).toHaveLength(2) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + expect(reasoningChunks[1].text).toBe(" I need to consider all options.") + + // Verify text content is processed correctly + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(2) // One for the text block, one for the newline + expect(textChunks[0].text).toBe("\n") + expect(textChunks[1].text).toBe("Here's my answer:") + }) + + it("should handle multiple thinking blocks with line breaks", async () => { + const mockStream = [ + { + type: "content_block_start", + index: 0, + content_block: { + type: "thinking", + thinking: "First thinking block", + }, + }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "thinking", + thinking: "Second thinking block", + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks: ApiStreamChunk[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBe(3) + expect(chunks[0]).toEqual({ + type: "reasoning", + text: "First thinking block", + }) + expect(chunks[1]).toEqual({ + type: "reasoning", + text: "\n", + }) + expect(chunks[2]).toEqual({ + type: "reasoning", + text: "Second thinking block", + }) + }) + }) + describe("completePrompt", () => { it("should complete prompt successfully", async () => { const result = await handler.completePrompt("Test prompt") @@ -500,4 +633,121 @@ describe("VertexHandler", () => { expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") // Default model }) }) + + describe("thinking model configuration", () => { + it("should configure thinking for models with :thinking suffix", () => { + const thinkingHandler = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 16384, + vertexThinking: 4096, + }) + + const modelInfo = thinkingHandler.getModel() + + // Verify thinking configuration + expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") + expect(modelInfo.thinking).toBeDefined() + const thinkingConfig = modelInfo.thinking as { type: "enabled"; budget_tokens: number } + expect(thinkingConfig.type).toBe("enabled") + expect(thinkingConfig.budget_tokens).toBe(4096) + expect(modelInfo.temperature).toBe(1.0) // Thinking requires temperature 1.0 + }) + + it("should calculate thinking budget correctly", () => { + // Test with explicit thinking budget + const handlerWithBudget = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 16384, + vertexThinking: 5000, + }) + + expect((handlerWithBudget.getModel().thinking as any).budget_tokens).toBe(5000) + + // Test with default thinking budget (80% of max tokens) + const handlerWithDefaultBudget = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 10000, + }) + + expect((handlerWithDefaultBudget.getModel().thinking as any).budget_tokens).toBe(8000) // 80% of 10000 + + // Test with minimum thinking budget (should be at least 1024) + const handlerWithSmallMaxTokens = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 1000, // This would result in 800 tokens for thinking, but minimum is 1024 + }) + + expect((handlerWithSmallMaxTokens.getModel().thinking as any).budget_tokens).toBe(1024) + }) + + it("should use anthropicThinking value if vertexThinking is not provided", () => { + const handler = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 16384, + anthropicThinking: 6000, // Should be used as fallback + }) + + expect((handler.getModel().thinking as any).budget_tokens).toBe(6000) + }) + + it("should pass thinking configuration to API", async () => { + const thinkingHandler = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 16384, + vertexThinking: 4096, + }) + + const mockCreate = jest.fn().mockImplementation(async (options) => { + if (!options.stream) { + return { + id: "test-completion", + content: [{ type: "text", text: "Test response" }], + role: "assistant", + model: options.model, + usage: { + input_tokens: 10, + output_tokens: 5, + }, + } + } + return { + async *[Symbol.asyncIterator]() { + yield { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 5, + }, + }, + } + }, + } + }) + ;(thinkingHandler["client"].messages as any).create = mockCreate + + await thinkingHandler + .createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }]) + .next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + thinking: { type: "enabled", budget_tokens: 4096 }, + temperature: 1.0, // Thinking requires temperature 1.0 + }), + ) + }) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx index 73394bae10..65ae137003 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx @@ -46,6 +46,21 @@ jest.mock("../TemperatureControl", () => ({ ), })) +// Mock ThinkingBudget component +jest.mock("../ThinkingBudget", () => ({ + ThinkingBudget: ({ apiConfiguration, setApiConfigurationField, modelInfo, provider }: any) => + modelInfo?.thinking ? ( +
+ +
+ ) : null, +})) + describe("ApiOptions", () => { const renderApiOptions = (props = {}) => { render( @@ -72,5 +87,45 @@ describe("ApiOptions", () => { expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument() }) - //TODO: More test cases needed + describe("thinking functionality", () => { + it("should show ThinkingBudget for Anthropic models that support thinking", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "anthropic", + apiModelId: "claude-3-7-sonnet-20250219:thinking", + }, + }) + + expect(screen.getByTestId("thinking-budget")).toBeInTheDocument() + expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "anthropic") + }) + + it("should show ThinkingBudget for Vertex models that support thinking", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "vertex", + apiModelId: "claude-3-7-sonnet@20250219:thinking", + }, + }) + + expect(screen.getByTestId("thinking-budget")).toBeInTheDocument() + expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "vertex") + }) + + it("should not show ThinkingBudget for models that don't support thinking", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "anthropic", + apiModelId: "claude-3-opus-20240229", + modelInfo: { thinking: false }, // Non-thinking model + }, + }) + + expect(screen.queryByTestId("thinking-budget")).not.toBeInTheDocument() + }) + + // Note: We don't need to test the actual ThinkingBudget component functionality here + // since we have separate tests for that component. We just need to verify that + // it's included in the ApiOptions component when appropriate. + }) }) diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx new file mode 100644 index 0000000000..54f6b1037b --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx @@ -0,0 +1,145 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { ThinkingBudget } from "../ThinkingBudget" +import { ApiProvider, ModelInfo } from "../../../../../src/shared/api" + +// Mock Slider component +jest.mock("@/components/ui", () => ({ + Slider: ({ value, onValueChange, min, max }: any) => ( + onValueChange([parseInt(e.target.value)])} + /> + ), +})) + +describe("ThinkingBudget", () => { + const mockModelInfo: ModelInfo = { + thinking: true, + maxTokens: 16384, + contextWindow: 200000, + supportsPromptCache: true, + supportsImages: true, + } + const defaultProps = { + apiConfiguration: {}, + setApiConfigurationField: jest.fn(), + modelInfo: mockModelInfo, + provider: "anthropic" as ApiProvider, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should render nothing when model doesn't support thinking", () => { + const { container } = render( + , + ) + + expect(container.firstChild).toBeNull() + }) + + it("should render sliders when model supports thinking", () => { + render() + + expect(screen.getAllByTestId("slider")).toHaveLength(2) + }) + + it("should use anthropicThinking field for Anthropic provider", () => { + const setApiConfigurationField = jest.fn() + + render( + , + ) + + const sliders = screen.getAllByTestId("slider") + fireEvent.change(sliders[1], { target: { value: "5000" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("anthropicThinking", 5000) + }) + + it("should use vertexThinking field for Vertex provider", () => { + const setApiConfigurationField = jest.fn() + + render( + , + ) + + const sliders = screen.getAllByTestId("slider") + fireEvent.change(sliders[1], { target: { value: "5000" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("vertexThinking", 5000) + }) + + it("should cap thinking tokens at 80% of max tokens", () => { + const setApiConfigurationField = jest.fn() + + render( + , + ) + + // Effect should trigger and cap the value + expect(setApiConfigurationField).toHaveBeenCalledWith("anthropicThinking", 8000) // 80% of 10000 + }) + + it("should use default thinking tokens if not provided", () => { + render() + + // Default is 80% of max tokens, capped at 8192 + const sliders = screen.getAllByTestId("slider") + expect(sliders[1]).toHaveValue("8000") // 80% of 10000 + }) + + it("should use min thinking tokens of 1024", () => { + render() + + const sliders = screen.getAllByTestId("slider") + expect(sliders[1].getAttribute("min")).toBe("1024") + }) + + it("should update max tokens when slider changes", () => { + const setApiConfigurationField = jest.fn() + + render( + , + ) + + const sliders = screen.getAllByTestId("slider") + fireEvent.change(sliders[0], { target: { value: "12000" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", 12000) + }) +}) From 2b3d23ebd750bfaf19efd6fbcc5bbcc8f1cb3aef Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti <105351510+lupuletic@users.noreply.github.com> Date: Thu, 27 Feb 2025 22:17:09 +0000 Subject: [PATCH 25/28] Update src/shared/globalState.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- src/shared/globalState.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 05b868a450..6e29e03835 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -44,7 +44,6 @@ export type GlobalStateKey = | "lmStudioBaseUrl" | "anthropicBaseUrl" | "anthropicThinking" - | "vertexThinking" | "azureApiVersion" | "openAiStreamingEnabled" | "openRouterModelId" From 87b70cef83bafcf8ea4751de165ba41c35b38ba2 Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti Date: Thu, 27 Feb 2025 22:20:35 +0000 Subject: [PATCH 26/28] Removed unnecessary comment --- src/shared/globalState.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 6e29e03835..05b868a450 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -44,6 +44,7 @@ export type GlobalStateKey = | "lmStudioBaseUrl" | "anthropicBaseUrl" | "anthropicThinking" + | "vertexThinking" | "azureApiVersion" | "openAiStreamingEnabled" | "openRouterModelId" From dd4fb6b3097430f98345e85d4c563e29baade089 Mon Sep 17 00:00:00 2001 From: Catalin Lupuleti <105351510+lupuletic@users.noreply.github.com> Date: Thu, 27 Feb 2025 22:45:12 +0000 Subject: [PATCH 27/28] Update src/shared/globalState.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- src/shared/globalState.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 05b868a450..6e29e03835 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -44,7 +44,6 @@ export type GlobalStateKey = | "lmStudioBaseUrl" | "anthropicBaseUrl" | "anthropicThinking" - | "vertexThinking" | "azureApiVersion" | "openAiStreamingEnabled" | "openRouterModelId" From 8cbce2ded08e107454dee7d2eec256973d3e85e0 Mon Sep 17 00:00:00 2001 From: cte Date: Thu, 27 Feb 2025 16:06:47 -0800 Subject: [PATCH 28/28] Add provider-agnostic modelMaxThinkingTokens setting --- src/api/providers/__tests__/vertex.test.ts | 18 +++--------------- src/api/providers/anthropic.ts | 2 +- src/api/providers/openrouter.ts | 2 +- src/api/providers/vertex.ts | 5 +---- src/core/webview/ClineProvider.ts | 15 +++++---------- .../__tests__/checkExistApiConfig.test.ts | 2 +- src/shared/api.ts | 3 +-- src/shared/globalState.ts | 3 +-- .../src/components/settings/ThinkingBudget.tsx | 13 +++++-------- .../settings/__tests__/ApiOptions.test.tsx | 7 +------ .../settings/__tests__/ThinkingBudget.test.tsx | 16 ++++++++-------- 11 files changed, 28 insertions(+), 58 deletions(-) diff --git a/src/api/providers/__tests__/vertex.test.ts b/src/api/providers/__tests__/vertex.test.ts index 076f902ca2..9cf92f0a16 100644 --- a/src/api/providers/__tests__/vertex.test.ts +++ b/src/api/providers/__tests__/vertex.test.ts @@ -641,7 +641,7 @@ describe("VertexHandler", () => { vertexProjectId: "test-project", vertexRegion: "us-central1", modelMaxTokens: 16384, - vertexThinking: 4096, + modelMaxThinkingTokens: 4096, }) const modelInfo = thinkingHandler.getModel() @@ -662,7 +662,7 @@ describe("VertexHandler", () => { vertexProjectId: "test-project", vertexRegion: "us-central1", modelMaxTokens: 16384, - vertexThinking: 5000, + modelMaxThinkingTokens: 5000, }) expect((handlerWithBudget.getModel().thinking as any).budget_tokens).toBe(5000) @@ -688,25 +688,13 @@ describe("VertexHandler", () => { expect((handlerWithSmallMaxTokens.getModel().thinking as any).budget_tokens).toBe(1024) }) - it("should use anthropicThinking value if vertexThinking is not provided", () => { - const handler = new VertexHandler({ - apiModelId: "claude-3-7-sonnet@20250219:thinking", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - modelMaxTokens: 16384, - anthropicThinking: 6000, // Should be used as fallback - }) - - expect((handler.getModel().thinking as any).budget_tokens).toBe(6000) - }) - it("should pass thinking configuration to API", async () => { const thinkingHandler = new VertexHandler({ apiModelId: "claude-3-7-sonnet@20250219:thinking", vertexProjectId: "test-project", vertexRegion: "us-central1", modelMaxTokens: 16384, - vertexThinking: 4096, + modelMaxThinkingTokens: 4096, }) const mockCreate = jest.fn().mockImplementation(async (options) => { diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index eca81eab2e..fc0b99c59b 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -206,7 +206,7 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler { // least 1024 tokens. const maxBudgetTokens = Math.floor(maxTokens * 0.8) const budgetTokens = Math.max( - Math.min(this.options.anthropicThinking ?? maxBudgetTokens, maxBudgetTokens), + Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens), 1024, ) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 69bcb0074c..82c02e20a7 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -117,7 +117,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler { // least 1024 tokens. const maxBudgetTokens = Math.floor((maxTokens || 8192) * 0.8) const budgetTokens = Math.max( - Math.min(this.options.anthropicThinking ?? maxBudgetTokens, maxBudgetTokens), + Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens), 1024, ) diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 69fb7d26f7..a25fad07ee 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -300,10 +300,7 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { temperature = 1.0 // Thinking requires temperature 1.0 const maxBudgetTokens = Math.floor(maxTokens * 0.8) const budgetTokens = Math.max( - Math.min( - this.options.vertexThinking ?? this.options.anthropicThinking ?? maxBudgetTokens, - maxBudgetTokens, - ), + Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens), 1024, ) thinking = { type: "enabled", budget_tokens: budgetTokens } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5417e54ff7..7b6f2c8971 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1651,8 +1651,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, - anthropicThinking, - vertexThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, @@ -1673,6 +1671,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelInfo, modelTemperature, modelMaxTokens, + modelMaxThinkingTokens, } = apiConfiguration await Promise.all([ this.updateGlobalState("apiProvider", apiProvider), @@ -1701,8 +1700,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("lmStudioModelId", lmStudioModelId), this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl), this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl), - this.updateGlobalState("anthropicThinking", anthropicThinking), - this.updateGlobalState("vertexThinking", vertexThinking), this.storeSecret("geminiApiKey", geminiApiKey), this.storeSecret("openAiNativeApiKey", openAiNativeApiKey), this.storeSecret("deepSeekApiKey", deepSeekApiKey), @@ -1723,6 +1720,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("requestyModelInfo", requestyModelInfo), this.updateGlobalState("modelTemperature", modelTemperature), this.updateGlobalState("modelMaxTokens", modelMaxTokens), + this.updateGlobalState("anthropicThinking", modelMaxThinkingTokens), ]) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) @@ -2159,8 +2157,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, - anthropicThinking, - vertexThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, @@ -2216,6 +2212,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelInfo, modelTemperature, modelMaxTokens, + modelMaxThinkingTokens, maxOpenTabsContext, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, @@ -2244,8 +2241,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("lmStudioModelId") as Promise, this.getGlobalState("lmStudioBaseUrl") as Promise, this.getGlobalState("anthropicBaseUrl") as Promise, - this.getGlobalState("anthropicThinking") as Promise, - this.getGlobalState("vertexThinking") as Promise, this.getSecret("geminiApiKey") as Promise, this.getSecret("openAiNativeApiKey") as Promise, this.getSecret("deepSeekApiKey") as Promise, @@ -2301,6 +2296,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("requestyModelInfo") as Promise, this.getGlobalState("modelTemperature") as Promise, this.getGlobalState("modelMaxTokens") as Promise, + this.getGlobalState("anthropicThinking") as Promise, this.getGlobalState("maxOpenTabsContext") as Promise, ]) @@ -2346,8 +2342,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, - anthropicThinking, - vertexThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, @@ -2368,6 +2362,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelInfo, modelTemperature, modelMaxTokens, + modelMaxThinkingTokens, }, lastShownAnnouncementId, customInstructions, diff --git a/src/shared/__tests__/checkExistApiConfig.test.ts b/src/shared/__tests__/checkExistApiConfig.test.ts index 62517d6958..c99ddddbc4 100644 --- a/src/shared/__tests__/checkExistApiConfig.test.ts +++ b/src/shared/__tests__/checkExistApiConfig.test.ts @@ -32,7 +32,7 @@ describe("checkExistKey", () => { apiKey: "test-key", apiProvider: undefined, anthropicBaseUrl: undefined, - anthropicThinking: undefined, + modelMaxThinkingTokens: undefined, } expect(checkExistKey(config)).toBe(true) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index b36781d630..f88bb5e8b5 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -21,7 +21,6 @@ export interface ApiHandlerOptions { apiModelId?: string apiKey?: string // anthropic anthropicBaseUrl?: string - anthropicThinking?: number vsCodeLmModelSelector?: vscode.LanguageModelChatSelector glamaModelId?: string glamaModelInfo?: ModelInfo @@ -41,7 +40,6 @@ export interface ApiHandlerOptions { awsUseProfile?: boolean vertexProjectId?: string vertexRegion?: string - vertexThinking?: number openAiBaseUrl?: string openAiApiKey?: string openAiModelId?: string @@ -70,6 +68,7 @@ export interface ApiHandlerOptions { requestyModelInfo?: ModelInfo modelTemperature?: number modelMaxTokens?: number + modelMaxThinkingTokens?: number } export type ApiConfiguration = ApiHandlerOptions & { diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 6e29e03835..aabc77cc01 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -24,7 +24,6 @@ export type GlobalStateKey = | "awsUseProfile" | "vertexProjectId" | "vertexRegion" - | "vertexThinking" | "lastShownAnnouncementId" | "customInstructions" | "alwaysAllowReadOnly" @@ -43,7 +42,6 @@ export type GlobalStateKey = | "lmStudioModelId" | "lmStudioBaseUrl" | "anthropicBaseUrl" - | "anthropicThinking" | "azureApiVersion" | "openAiStreamingEnabled" | "openRouterModelId" @@ -83,5 +81,6 @@ export type GlobalStateKey = | "unboundModelInfo" | "modelTemperature" | "modelMaxTokens" + | "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`. | "mistralCodestralUrl" | "maxOpenTabsContext" diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index d21e1fb7ea..557a69538d 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -17,27 +17,24 @@ export const ThinkingBudget = ({ modelInfo, provider, }: ThinkingBudgetProps) => { - const isVertexProvider = provider === "vertex" - const budgetField = isVertexProvider ? "vertexThinking" : "anthropicThinking" - const tokens = apiConfiguration?.modelMaxTokens || modelInfo?.maxTokens || 64_000 const tokensMin = 8192 const tokensMax = modelInfo?.maxTokens || 64_000 // Get the appropriate thinking tokens based on provider const thinkingTokens = useMemo(() => { - const value = isVertexProvider ? apiConfiguration?.vertexThinking : apiConfiguration?.anthropicThinking + const value = apiConfiguration?.modelMaxThinkingTokens return value || Math.min(Math.floor(0.8 * tokens), 8192) - }, [apiConfiguration, isVertexProvider, tokens]) + }, [apiConfiguration, tokens]) const thinkingTokensMin = 1024 const thinkingTokensMax = Math.floor(0.8 * tokens) useEffect(() => { if (thinkingTokens > thinkingTokensMax) { - setApiConfigurationField(budgetField, thinkingTokensMax) + setApiConfigurationField("modelMaxThinkingTokens", thinkingTokensMax) } - }, [thinkingTokens, thinkingTokensMax, setApiConfigurationField, budgetField]) + }, [thinkingTokens, thinkingTokensMax, setApiConfigurationField]) if (!modelInfo?.thinking) { return null @@ -66,7 +63,7 @@ export const ThinkingBudget = ({ max={thinkingTokensMax} step={1024} value={[thinkingTokens]} - onValueChange={([value]) => setApiConfigurationField(budgetField, value)} + onValueChange={([value]) => setApiConfigurationField("modelMaxThinkingTokens", value)} />
{thinkingTokens}
diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx index 65ae137003..06ed95585a 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx @@ -51,12 +51,7 @@ jest.mock("../ThinkingBudget", () => ({ ThinkingBudget: ({ apiConfiguration, setApiConfigurationField, modelInfo, provider }: any) => modelInfo?.thinking ? (
- +
) : null, })) diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx index 54f6b1037b..212316ea9a 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx @@ -60,13 +60,13 @@ describe("ThinkingBudget", () => { expect(screen.getAllByTestId("slider")).toHaveLength(2) }) - it("should use anthropicThinking field for Anthropic provider", () => { + it("should use modelMaxThinkingTokens field for Anthropic provider", () => { const setApiConfigurationField = jest.fn() render( , @@ -75,16 +75,16 @@ describe("ThinkingBudget", () => { const sliders = screen.getAllByTestId("slider") fireEvent.change(sliders[1], { target: { value: "5000" } }) - expect(setApiConfigurationField).toHaveBeenCalledWith("anthropicThinking", 5000) + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000) }) - it("should use vertexThinking field for Vertex provider", () => { + it("should use modelMaxThinkingTokens field for Vertex provider", () => { const setApiConfigurationField = jest.fn() render( , @@ -93,7 +93,7 @@ describe("ThinkingBudget", () => { const sliders = screen.getAllByTestId("slider") fireEvent.change(sliders[1], { target: { value: "5000" } }) - expect(setApiConfigurationField).toHaveBeenCalledWith("vertexThinking", 5000) + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000) }) it("should cap thinking tokens at 80% of max tokens", () => { @@ -102,13 +102,13 @@ describe("ThinkingBudget", () => { render( , ) // Effect should trigger and cap the value - expect(setApiConfigurationField).toHaveBeenCalledWith("anthropicThinking", 8000) // 80% of 10000 + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 8000) // 80% of 10000 }) it("should use default thinking tokens if not provided", () => {