diff --git a/.changeset/chilly-bats-joke.md b/.changeset/chilly-bats-joke.md new file mode 100644 index 0000000000..204445dced --- /dev/null +++ b/.changeset/chilly-bats-joke.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Fixed problem with “win+shift+a” shortcut not working in Windows diff --git a/.changeset/clever-apes-thank.md b/.changeset/clever-apes-thank.md new file mode 100644 index 0000000000..39fc96166d --- /dev/null +++ b/.changeset/clever-apes-thank.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Better Openrouter error typing and throwing more detailed messages diff --git a/.changeset/cool-rice-chew.md b/.changeset/cool-rice-chew.md deleted file mode 100644 index 7d565030cf..0000000000 --- a/.changeset/cool-rice-chew.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"claude-dev": patch ---- - -Add retries to Bedrock createMessage diff --git a/.changeset/funny-suns-glow.md b/.changeset/funny-suns-glow.md new file mode 100644 index 0000000000..186d1c948f --- /dev/null +++ b/.changeset/funny-suns-glow.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Add webview to lint job diff --git a/.changeset/great-berries-heal.md b/.changeset/great-berries-heal.md deleted file mode 100644 index 37fa727043..0000000000 --- a/.changeset/great-berries-heal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"claude-dev": minor ---- - -Update Claude 3.5 -> Claude 3.7 in error message/recommendation diff --git a/.changeset/green-forks-change.md b/.changeset/green-forks-change.md deleted file mode 100644 index 6931023a3f..0000000000 --- a/.changeset/green-forks-change.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"claude-dev": patch ---- - -Add support for AskSage as model provider. diff --git a/.changeset/tidy-kings-ring.md b/.changeset/tidy-kings-ring.md deleted file mode 100644 index 8ae4f03934..0000000000 --- a/.changeset/tidy-kings-ring.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"claude-dev": patch ---- - -Add timeout option to MCP servers diff --git a/.changeset/twelve-terms-kiss.md b/.changeset/twelve-terms-kiss.md deleted file mode 100644 index a6daecd2ca..0000000000 --- a/.changeset/twelve-terms-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"claude-dev": patch ---- - -Add APAC Support for Cross-Region Inference Profiles diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dd04190cf1..727cb6fdf2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @saoudrizwan @ocasta181 @NightTrek @pashpashpash +* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba219e4f9..4dc8ea2514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.5.1] + +- Add timeout option to MCP servers +- Add Gemini Flash models to Vertex provider (thanks @jpaodev!) +- Add prompt caching support for AWS Bedrock provider (thanks @buger!) +- Add AskSage provider (thanks @swhite24!) + ## [3.5.0] - Add 'Enable extended thinking' option for Claude 3.7 Sonnet, with ability to set different budgets for Plan and Act modes diff --git a/package-lock.json b/package-lock.json index 03492b5371..5e28dd0483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "claude-dev", - "version": "3.5.0", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-dev", - "version": "3.5.0", + "version": "3.5.1", "license": "Apache-2.0", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.12.4", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.6.4", + "@google-cloud/vertexai": "^1.9.3", "@google/generative-ai": "^0.18.0", "@mistralai/mistralai": "^1.5.0", "@modelcontextprotocol/sdk": "^1.0.1", @@ -36,6 +37,7 @@ "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", + "ollama": "^0.5.13", "open-graph-scraper": "^6.9.0", "openai": "^4.83.0", "os-name": "^6.0.0", @@ -3787,6 +3789,18 @@ "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==" }, + "node_modules/@google-cloud/vertexai": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.9.3.tgz", + "integrity": "sha512-35o5tIEMLW3JeFJOaaMNR2e5sq+6rpnhrF97PuAxeOm0GlqVTESKhkGj7a5B5mmJSSSU3hUfIhcQCRRsw4Ipzg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@google/generative-ai": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.18.0.tgz", @@ -11051,6 +11065,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ollama": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.13.tgz", + "integrity": "sha512-qK3eE2GjMYjCiTknEJfAHjbUzUqgVtf9qtzjxWrkwBZgBG7kOB6Z4+Ov4fbvDjmKKHv+rpuTsWFg4jZvVjNBtQ==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -13401,6 +13424,12 @@ "node": ">=18" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", diff --git a/package.json b/package.json index 33e02bc052..870f3cb294 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "claude-dev", "displayName": "Cline", "description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.", - "version": "3.5.0", + "version": "3.5.1", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", @@ -236,7 +236,7 @@ "watch-tests": "tsc -p . -w --outDir out", "pretest": "npm run compile-tests && npm run compile && npm run lint", "check-types": "tsc --noEmit", - "lint": "eslint src --ext ts", + "lint": "eslint src --ext ts && eslint webview-ui/src --ext ts", "format": "prettier . --check", "format:fix": "prettier . --write", "test": "vscode-test", @@ -275,6 +275,7 @@ "@anthropic-ai/bedrock-sdk": "^0.12.4", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.6.4", + "@google-cloud/vertexai": "^1.9.3", "@google/generative-ai": "^0.18.0", "@mistralai/mistralai": "^1.5.0", "@modelcontextprotocol/sdk": "^1.0.1", @@ -299,6 +300,7 @@ "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", + "ollama": "^0.5.13", "open-graph-scraper": "^6.9.0", "openai": "^4.83.0", "os-name": "^6.0.0", diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 93804d99c2..67e1425482 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -19,11 +19,13 @@ export class AnthropicHandler implements ApiHandler { @withRetry() async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - let budget_tokens = this.options.thinkingBudgetTokens || 0 - const reasoningOn = budget_tokens !== 0 ? true : false const model = this.getModel() let stream: AnthropicStream const modelId = model.id + + let budget_tokens = this.options.thinkingBudgetTokens || 0 + const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false + switch (modelId) { // 'latest' alias does not support cache_control case "claude-3-7-sonnet-20250219": diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 1cd64354d0..7d2ce21a7f 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -16,11 +16,12 @@ export class AwsBedrockHandler implements ApiHandler { @withRetry() async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - let budget_tokens = this.options.thinkingBudgetTokens || 0 - const reasoningOn = budget_tokens !== 0 ? true : false // cross region inference requires prefixing the model id with the region let modelId = await this.getModelId() + let budget_tokens = this.options.thinkingBudgetTokens || 0 + const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false + // Get model info and message indices for caching const model = this.getModel() const userMsgIndices = messages.reduce((acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[]) @@ -33,11 +34,47 @@ export class AwsBedrockHandler implements ApiHandler { const stream = await client.messages.create({ model: modelId, - max_tokens: this.getModel().info.maxTokens || 8192, + max_tokens: model.info.maxTokens || 8192, thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined, temperature: reasoningOn ? undefined : 0, - system: systemPrompt, - messages, + system: [ + { + text: systemPrompt, + type: "text", + ...(this.options.awsBedrockUsePromptCache === true && { + cache_control: { type: "ephemeral" }, + }), + }, + ], + messages: messages.map((message, index) => { + if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { + return { + ...message, + content: + typeof message.content === "string" + ? [ + { + type: "text", + text: message.content, + ...(this.options.awsBedrockUsePromptCache === true && { + cache_control: { type: "ephemeral" }, + }), + }, + ] + : message.content.map((content, contentIndex) => + contentIndex === message.content.length - 1 + ? { + ...content, + ...(this.options.awsBedrockUsePromptCache === true && { + cache_control: { type: "ephemeral" }, + }), + } + : content, + ), + } + } + return message + }), stream: true, }) diff --git a/src/api/providers/ollama.ts b/src/api/providers/ollama.ts index 7668bd395f..9b69ce4984 100644 --- a/src/api/providers/ollama.ts +++ b/src/api/providers/ollama.ts @@ -1,40 +1,35 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { Message, Ollama } from "ollama" import { ApiHandler } from "../" import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { convertToOllamaMessages } from "../transform/ollama-format" import { ApiStream } from "../transform/stream" export class OllamaHandler implements ApiHandler { private options: ApiHandlerOptions - private client: OpenAI + private client: Ollama constructor(options: ApiHandlerOptions) { this.options = options - this.client = new OpenAI({ - baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1", - apiKey: "ollama", - }) + this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" }) } async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] + const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)] - const stream = await this.client.chat.completions.create({ + const stream = await this.client.chat({ model: this.getModel().id, - messages: openAiMessages, - temperature: 0, + messages: ollamaMessages, stream: true, + options: { + num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768, + }, }) for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - if (delta?.content) { + if (typeof chunk.message.content === "string") { yield { type: "text", - text: delta.content, + text: chunk.message.content, } } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 7309d5bb98..a5c51adada 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -6,6 +6,7 @@ import { ApiHandler } from "../index" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" import { convertToR1Format } from "../transform/r1-format" +import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs" export class OpenAiHandler implements ApiHandler { private options: ApiHandlerOptions @@ -32,20 +33,30 @@ export class OpenAiHandler implements ApiHandler { async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { const modelId = this.options.openAiModelId ?? "" const isDeepseekReasoner = modelId.includes("deepseek-reasoner") + const isO3Mini = modelId.includes("o3-mini") let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages), ] + let temperature: number | undefined = 0 + let reasoningEffort: ChatCompletionReasoningEffort | undefined = undefined if (isDeepseekReasoner) { openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) } + if (isO3Mini) { + openAiMessages = [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)] + temperature = undefined // does not support temperature + reasoningEffort = (this.options.o3MiniReasoningEffort as ChatCompletionReasoningEffort) || "medium" + } + const stream = await this.client.chat.completions.create({ model: modelId, messages: openAiMessages, - temperature: 0, + temperature, + reasoning_effort: reasoningEffort, stream: true, stream_options: { include_usage: true }, }) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 56beae8fb7..05fd74c915 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -7,6 +7,8 @@ import { ApiHandler } from "../" import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api" import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream" import { ApiStream } from "../transform/stream" +import { convertToR1Format } from "../transform/r1-format" +import { OpenRouterErrorResponse } from "./types" export class OpenRouterHandler implements ApiHandler { private options: ApiHandlerOptions diff --git a/src/api/providers/types.ts b/src/api/providers/types.ts new file mode 100644 index 0000000000..c90dc7c5b8 --- /dev/null +++ b/src/api/providers/types.ts @@ -0,0 +1,22 @@ +// For the following openrouter error type sources, see the docs here: +// https://openrouter.ai/docs/api-reference/errors + +export type OpenRouterErrorResponse = { + error: { + message: string + code: number + metadata?: OpenRouterProviderErrorMetadata | OpenRouterModerationErrorMetadata | Record + } +} + +export type OpenRouterProviderErrorMetadata = { + provider_name: string // The name of the provider that encountered the error + raw: unknown // The raw error from the provider +} + +export type OpenRouterModerationErrorMetadata = { + reasons: string[] // Why your input was flagged + flagged_input: string // The text segment that was flagged, limited to 100 characters. If the flagged input is longer than 100 characters, it will be truncated in the middle and replaced with ... + provider_name: string // The name of the provider that requested moderation + model_slug: string +} diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index c88ceb5028..2ba064cab7 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -4,19 +4,25 @@ import { withRetry } from "../retry" import { ApiHandler } from "../" import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api" import { ApiStream } from "../transform/stream" +import { VertexAI } from "@google-cloud/vertexai" // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class VertexHandler implements ApiHandler { private options: ApiHandlerOptions - private client: AnthropicVertex + private clientAnthropic: AnthropicVertex + private clientVertex: VertexAI constructor(options: ApiHandlerOptions) { this.options = options - this.client = new AnthropicVertex({ + this.clientAnthropic = new AnthropicVertex({ projectId: this.options.vertexProjectId, // https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions region: this.options.vertexRegion, }) + this.clientVertex = new VertexAI({ + project: this.options.vertexProjectId, + location: this.options.vertexRegion, + }) } @withRetry() @@ -24,40 +30,66 @@ export class VertexHandler implements ApiHandler { const model = this.getModel() const modelId = model.id - let budget_tokens = this.options.thinkingBudgetTokens || 0 - const reasoningOn = budget_tokens !== 0 ? true : false + if (modelId.includes("claude")) { + let budget_tokens = this.options.thinkingBudgetTokens || 0 + const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false - let stream - switch (modelId) { - case "claude-3-7-sonnet@20250219": - case "claude-3-5-sonnet-v2@20241022": - case "claude-3-5-sonnet@20240620": - case "claude-3-5-haiku@20241022": - case "claude-3-opus@20240229": - case "claude-3-haiku@20240307": { - // Find indices of user messages for cache control - const userMsgIndices = messages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], - ) - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + let stream + switch (modelId) { + case "claude-3-7-sonnet@20250219": + case "claude-3-5-sonnet-v2@20241022": + case "claude-3-5-sonnet@20240620": + case "claude-3-5-haiku@20241022": + case "claude-3-opus@20240229": + case "claude-3-haiku@20240307": { + // Find indices of user messages for cache control + const userMsgIndices = messages.reduce( + (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), + [] as number[], + ) + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - stream = await this.client.beta.messages.create( - { - model: modelId, - max_tokens: model.info.maxTokens || 8192, - thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined, - temperature: reasoningOn ? undefined : 0, - system: [ - { - text: systemPrompt, - type: "text", - cache_control: { type: "ephemeral" }, - }, - ], - messages: messages.map((message, index) => { - if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { + stream = await this.clientAnthropic.beta.messages.create( + { + model: modelId, + max_tokens: model.info.maxTokens || 8192, + thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined, + temperature: reasoningOn ? undefined : 0, + system: [ + { + text: systemPrompt, + type: "text", + cache_control: { type: "ephemeral" }, + }, + ], + messages: messages.map((message, index) => { + if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { + return { + ...message, + content: + typeof message.content === "string" + ? [ + { + type: "text", + text: message.content, + cache_control: { + type: "ephemeral", + }, + }, + ] + : message.content.map((content, contentIndex) => + contentIndex === message.content.length - 1 + ? { + ...content, + cache_control: { + type: "ephemeral", + }, + } + : content, + ), + } + } return { ...message, content: @@ -66,121 +98,152 @@ export class VertexHandler implements ApiHandler { { type: "text", text: message.content, - cache_control: { - type: "ephemeral", - }, }, ] - : message.content.map((content, contentIndex) => - contentIndex === message.content.length - 1 - ? { - ...content, - cache_control: { - type: "ephemeral", - }, - } - : content, - ), + : message.content, } - } - return { - ...message, - content: - typeof message.content === "string" - ? [ - { - type: "text", - text: message.content, - }, - ] - : message.content, - } - }), - stream: true, - }, - { - headers: {}, - }, - ) - break - } - default: { - stream = await this.client.beta.messages.create({ - model: modelId, - max_tokens: model.info.maxTokens || 8192, - temperature: 0, - system: [ - { - text: systemPrompt, - type: "text", + }), + stream: true, }, - ], - messages: messages.map((message) => ({ - ...message, - content: - typeof message.content === "string" - ? [ - { - type: "text", - text: message.content, - }, - ] - : message.content, - })), - stream: true, - }) - break + { + headers: {}, + }, + ) + break + } + default: { + stream = await this.clientAnthropic.beta.messages.create({ + model: modelId, + max_tokens: model.info.maxTokens || 8192, + temperature: 0, + system: [ + { + text: systemPrompt, + type: "text", + }, + ], + messages: messages.map((message) => ({ + ...message, + content: + typeof message.content === "string" + ? [ + { + type: "text", + text: message.content, + }, + ] + : message.content, + })), + stream: true, + }) + break + } } - } - for await (const chunk of stream) { - switch (chunk.type) { - 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 || undefined, - cacheReadTokens: usage.cache_read_input_tokens || undefined, - } - break - case "message_delta": - yield { - type: "usage", - inputTokens: 0, - outputTokens: chunk.usage.output_tokens || 0, - } - break - case "message_stop": - break - case "content_block_start": - switch (chunk.content_block.type) { - case "text": - if (chunk.index > 0) { + for await (const chunk of stream) { + switch (chunk.type) { + 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 || undefined, + cacheReadTokens: usage.cache_read_input_tokens || undefined, + } + break + case "message_delta": + yield { + type: "usage", + inputTokens: 0, + outputTokens: chunk.usage.output_tokens || 0, + } + break + case "message_stop": + break + case "content_block_start": + switch (chunk.content_block.type) { + case "text": + if (chunk.index > 0) { + yield { + type: "text", + text: "\n", + } + } yield { type: "text", - text: "\n", + text: chunk.content_block.text, } + break + } + break + case "content_block_delta": + switch (chunk.delta.type) { + case "text_delta": + yield { + type: "text", + text: chunk.delta.text, + } + break + } + break + case "content_block_stop": + break + } + } + } else { + // gemini + const generativeModel = this.clientVertex.getGenerativeModel({ + model: this.getModel().id, + systemInstruction: { + role: "system", + parts: [{ text: systemPrompt }], + }, + }) + const request = { + contents: [ + { + role: "user", + parts: messages.map((m) => { + if (typeof m.content === "string") { + return { text: m.content } + } else if (Array.isArray(m.content)) { + return { + text: m.content + .map((block) => { + if (typeof block === "string") { + return block + } else if (block.type === "text") { + return block.text + } else { + console.log("Unsupported block type", block) + return "" + } + }) + .join(" "), + } + } else { + return { text: "" } } + }), + }, + ], + } + const streamingResult = await generativeModel.generateContentStream(request) + for await (const chunk of streamingResult.stream) { + // If usage data is available, yield it similarly: + // yield { type: "usage", inputTokens: 0, outputTokens: 0 } + // Otherwise, just yield text: + const candidates = chunk.candidates || [] + for (const candidate of candidates) { + for (const part of candidate.content?.parts || []) { + if (part.text) { yield { type: "text", - text: chunk.content_block.text, + text: part.text, } - break + } } - break - case "content_block_delta": - switch (chunk.delta.type) { - case "text_delta": - yield { - type: "text", - text: chunk.delta.text, - } - break - } - break - case "content_block_stop": - break + } } } } diff --git a/src/api/transform/ollama-format.ts b/src/api/transform/ollama-format.ts new file mode 100644 index 0000000000..4f6bde4f2d --- /dev/null +++ b/src/api/transform/ollama-format.ts @@ -0,0 +1,109 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import { Message } from "ollama" + +export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { + const ollamaMessages: Message[] = [] + + for (const anthropicMessage of anthropicMessages) { + if (typeof anthropicMessage.content === "string") { + ollamaMessages.push({ + role: anthropicMessage.role, + content: anthropicMessage.content, + }) + } else { + if (anthropicMessage.role === "user") { + const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ + nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] + toolMessages: Anthropic.ToolResultBlockParam[] + }>( + (acc, part) => { + if (part.type === "tool_result") { + acc.toolMessages.push(part) + } else if (part.type === "text" || part.type === "image") { + acc.nonToolMessages.push(part) + } + return acc + }, + { nonToolMessages: [], toolMessages: [] }, + ) + + // Process tool result messages FIRST since they must follow the tool use messages + let toolResultImages: string[] = [] + toolMessages.forEach((toolMessage) => { + // The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility. + let content: string + + if (typeof toolMessage.content === "string") { + content = toolMessage.content + } else { + content = + toolMessage.content + ?.map((part) => { + if (part.type === "image") { + toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`) + return "(see following user message for image)" + } + return part.text + }) + .join("\n") ?? "" + } + ollamaMessages.push({ + role: "user", + images: toolResultImages.length > 0 ? toolResultImages : undefined, + content: content, + }) + }) + + // Process non-tool messages + if (nonToolMessages.length > 0) { + ollamaMessages.push({ + role: "user", + content: nonToolMessages + .map((part) => { + if (part.type === "image") { + return `data:${part.source.media_type};base64,${part.source.data}` + } + return part.text + }) + .join("\n"), + }) + } + } else if (anthropicMessage.role === "assistant") { + const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ + nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] + toolMessages: Anthropic.ToolUseBlockParam[] + }>( + (acc, part) => { + if (part.type === "tool_use") { + acc.toolMessages.push(part) + } else if (part.type === "text" || part.type === "image") { + acc.nonToolMessages.push(part) + } // assistant cannot send tool_result messages + return acc + }, + { nonToolMessages: [], toolMessages: [] }, + ) + + // Process non-tool messages + let content: string = "" + if (nonToolMessages.length > 0) { + content = nonToolMessages + .map((part) => { + if (part.type === "image") { + return "" // impossible as the assistant cannot send images + } + return part.text + }) + .join("\n") + } + + ollamaMessages.push({ + role: "assistant", + content, + }) + } + } + } + + return ollamaMessages +} diff --git a/src/api/transform/openrouter-stream.ts b/src/api/transform/openrouter-stream.ts index c5316eda04..4fb0d26603 100644 --- a/src/api/transform/openrouter-stream.ts +++ b/src/api/transform/openrouter-stream.ts @@ -4,6 +4,7 @@ import { convertToR1Format } from "./r1-format" import { ApiStream, ApiStreamChunk } from "./stream" import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +import { OpenRouterErrorResponse } from "../providers/types" export async function* streamOpenRouterFormatRequest( client: OpenAI, @@ -146,9 +147,11 @@ export async function* streamOpenRouterFormatRequest( for await (const chunk of stream) { // openrouter returns an error object instead of the openai sdk throwing an error if ("error" in chunk) { - const error = chunk.error as { message?: string; code?: number } + const error = chunk.error as OpenRouterErrorResponse["error"] console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`) - throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`) + // Include metadata in the error message if available + const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : "" + throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`) } if (!genId && chunk.id) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c055df1f8f..4e6cf971bb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -79,6 +79,7 @@ type GlobalStateKey = | "openAiModelInfo" | "ollamaModelId" | "ollamaBaseUrl" + | "ollamaApiOptionsCtxNum" | "lmStudioModelId" | "lmStudioBaseUrl" | "anthropicBaseUrl" @@ -579,6 +580,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { openAiModelInfo, ollamaModelId, ollamaBaseUrl, + ollamaApiOptionsCtxNum, lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, @@ -624,6 +626,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("openAiModelInfo", openAiModelInfo) await this.updateGlobalState("ollamaModelId", ollamaModelId) await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl) + await this.updateGlobalState("ollamaApiOptionsCtxNum", ollamaApiOptionsCtxNum) await this.updateGlobalState("lmStudioModelId", lmStudioModelId) await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl) await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl) @@ -1901,6 +1904,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont openAiModelInfo, ollamaModelId, ollamaBaseUrl, + ollamaApiOptionsCtxNum, lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, @@ -1959,6 +1963,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont this.getGlobalState("openAiModelInfo") as Promise, this.getGlobalState("ollamaModelId") as Promise, this.getGlobalState("ollamaBaseUrl") as Promise, + this.getGlobalState("ollamaApiOptionsCtxNum") as Promise, this.getGlobalState("lmStudioModelId") as Promise, this.getGlobalState("lmStudioBaseUrl") as Promise, this.getGlobalState("anthropicBaseUrl") as Promise, @@ -2040,6 +2045,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont openAiModelInfo, ollamaModelId, ollamaBaseUrl, + ollamaApiOptionsCtxNum, lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, diff --git a/src/shared/api.ts b/src/shared/api.ts index 386887cd8a..97333a22a6 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -46,6 +46,7 @@ export interface ApiHandlerOptions { openAiModelInfo?: ModelInfo ollamaModelId?: string ollamaBaseUrl?: string + ollamaApiOptionsCtxNum?: string lmStudioModelId?: string lmStudioBaseUrl?: string geminiApiKey?: string @@ -298,6 +299,78 @@ export const vertexModels = { cacheWritesPrice: 0.3, cacheReadsPrice: 0.03, }, + "gemini-2.0-flash-001": { + maxTokens: 8192, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0.1, + outputPrice: 0.4, + }, + "gemini-2.0-flash-thinking-exp-1219": { + maxTokens: 8192, + contextWindow: 32_767, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + "gemini-2.0-flash-exp": { + maxTokens: 8192, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + "gemini-exp-1206": { + maxTokens: 8192, + contextWindow: 2_097_152, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + "gemini-1.5-flash-002": { + maxTokens: 8192, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + "gemini-1.5-flash-exp-0827": { + maxTokens: 8192, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + "gemini-1.5-flash-8b-exp-0827": { + maxTokens: 8192, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + "gemini-1.5-pro-002": { + maxTokens: 8192, + contextWindow: 2_097_152, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + "gemini-1.5-pro-exp-0827": { + maxTokens: 8192, + contextWindow: 2_097_152, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, } as const satisfies Record export const openAiModelInfoSaneDefaults: ModelInfo = { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index f68d4c9bb3..0d6382d692 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -815,7 +815,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi {isExpanded ? (
- Reasoning + Thinking Model ID + + Model Context Window + {ollamaModels.length > 0 && ( ` border-radius: 50%; background: var(--vscode-foreground); cursor: pointer; - border: 2px solid var(--vscode-progressBar-background); + border: 0px solid var(--vscode-progressBar-background); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index dca4ea8413..ad8546e474 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -180,7 +180,9 @@ export function getContextMenuOptions( const seen = new Set() const deduped = allItems.filter((item) => { const key = `${item.type}-${item.value}` - if (seen.has(key)) return false + if (seen.has(key)) { + return false + } seen.add(key) return true }) @@ -195,18 +197,26 @@ export function shouldShowContextMenu(text: string, position: number): boolean { const beforeCursor = text.slice(0, position) const atIndex = beforeCursor.lastIndexOf("@") - if (atIndex === -1) return false + if (atIndex === -1) { + return false + } const textAfterAt = beforeCursor.slice(atIndex + 1) // Check if there's any whitespace after the '@' - if (/\s/.test(textAfterAt)) return false + if (/\s/.test(textAfterAt)) { + return false + } // Don't show the menu if it's a URL - if (textAfterAt.toLowerCase().startsWith("http")) return false + if (textAfterAt.toLowerCase().startsWith("http")) { + return false + } // Don't show the menu if it's a problems or terminal - if (textAfterAt.toLowerCase().startsWith("problems") || textAfterAt.toLowerCase().startsWith("terminal")) return false + if (textAfterAt.toLowerCase().startsWith("problems") || textAfterAt.toLowerCase().startsWith("terminal")) { + return false + } // NOTE: it's okay that menu shows when there's trailing punctuation since user could be inputting a path with marks diff --git a/webview-ui/src/utils/hooks.ts b/webview-ui/src/utils/hooks.ts index 18100c07e3..d526cae65e 100644 --- a/webview-ui/src/utils/hooks.ts +++ b/webview-ui/src/utils/hooks.ts @@ -50,8 +50,10 @@ export const useShortcut = (shortcut: string, callback: any, options = { disable if (Object.keys(modifierMap).includes(keyArray[0])) { const finalKey = keyArray.pop() + if (!finalKey) return - if (keyArray.every((k) => modifierMap[k]) && finalKey === event.key) { + if (keyArray.every((k) => modifierMap[k]) && finalKey.toLowerCase() === event.key.toLowerCase()) { + event.preventDefault() return callbackRef.current(event) } } else { diff --git a/webview-ui/src/utils/mcp.ts b/webview-ui/src/utils/mcp.ts index 4c823598b6..2a4fce51fa 100644 --- a/webview-ui/src/utils/mcp.ts +++ b/webview-ui/src/utils/mcp.ts @@ -35,7 +35,9 @@ export function findMatchingResourceOrTemplate( ): McpResource | McpResourceTemplate | undefined { // First try to find an exact resource match const exactMatch = resources.find((resource) => resource.uri === uri) - if (exactMatch) return exactMatch + if (exactMatch) { + return exactMatch + } // If no exact match, try to find a matching template return findMatchingTemplate(uri, templates) diff --git a/webview-ui/src/utils/useDebounceEffect.ts b/webview-ui/src/utils/useDebounceEffect.ts index b1374ff68d..4a8e1da65a 100644 --- a/webview-ui/src/utils/useDebounceEffect.ts +++ b/webview-ui/src/utils/useDebounceEffect.ts @@ -36,7 +36,5 @@ export function useDebounceEffect(effect: VoidFn, delay: number, deps: any[]) { // We want to re‐schedule if any item in `deps` changed, // or if `delay` changed. - - // eslint-disable-next-line react-hooks/exhaustive-deps }, [delay, ...deps]) } diff --git a/webview-ui/src/utils/vscStyles.ts b/webview-ui/src/utils/vscStyles.ts index ac8d34a0b9..c0b0eff34d 100644 --- a/webview-ui/src/utils/vscStyles.ts +++ b/webview-ui/src/utils/vscStyles.ts @@ -35,7 +35,9 @@ export function hexToRGB(hexColor: string): { r: number; g: number; b: number } export function colorToHex(colorVar: string): string { const value = getComputedStyle(document.documentElement).getPropertyValue(colorVar).trim() - if (value.startsWith("#")) return value.slice(0, 7) + if (value.startsWith("#")) { + return value.slice(0, 7) + } const rgbValues = value.match(/\d+/g)?.slice(0, 3).map(Number) || [] return `#${rgbValues.map((x) => x.toString(16).padStart(2, "0")).join("")}`