diff --git a/src/api/index.ts b/src/api/index.ts index d3308df5c6..061b61b8be 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { ApiConfiguration, ModelInfo } from "../shared/api" +import { ApiConfiguration, ModelInfo, ModelType } from "../shared/api" import { AnthropicHandler } from "./providers/anthropic" import { AwsBedrockHandler } from "./providers/bedrock" import { OpenRouterHandler } from "./providers/openrouter" @@ -14,8 +14,9 @@ import { DeepSeekHandler } from "./providers/deepseek" import { MistralHandler } from "./providers/mistral" export interface ApiHandler { - createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream + createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], modelType?: ModelType): ApiStream getModel(): { id: string; info: ModelInfo } + getAdvisorModel?(): { id: string; info: ModelInfo } } export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 3b9d7a354a..ce91c2f1ed 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -2,7 +2,15 @@ import { Anthropic } from "@anthropic-ai/sdk" import axios from "axios" import OpenAI from "openai" import { ApiHandler } from "../" -import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api" +import { + ApiHandlerOptions, + ModelInfo, + ModelType, + openRouterDefaultAdvisorModelId, + openRouterDefaultAdvisorModelInfo, + openRouterDefaultModelId, + openRouterDefaultModelInfo, +} from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" import delay from "delay" @@ -23,7 +31,9 @@ export class OpenRouterHandler implements ApiHandler { }) } - async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], modelType?: ModelType): ApiStream { + const model = modelType === "advisor" ? this.getAdvisorModel() : this.getModel() + // Convert Anthropic messages to OpenAI format const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, @@ -32,7 +42,7 @@ export class OpenRouterHandler implements ApiHandler { // prompt caching: https://openrouter.ai/docs/prompt-caching // this is specifically for claude models (some models may 'support prompt caching' automatically without this) - switch (this.getModel().id) { + switch (model.id) { case "anthropic/claude-3.5-sonnet": case "anthropic/claude-3.5-sonnet:beta": case "anthropic/claude-3.5-sonnet-20240620": @@ -83,7 +93,7 @@ export class OpenRouterHandler implements ApiHandler { // Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192. // (models usually default to max tokens allowed) let maxTokens: number | undefined - switch (this.getModel().id) { + switch (model.id) { case "anthropic/claude-3.5-sonnet": case "anthropic/claude-3.5-sonnet:beta": case "anthropic/claude-3.5-sonnet-20240620": @@ -97,15 +107,15 @@ export class OpenRouterHandler implements ApiHandler { } // Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache. - let shouldApplyMiddleOutTransform = !this.getModel().info.supportsPromptCache + let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache // except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this) - if (this.getModel().id === "deepseek/deepseek-chat") { + if (model.id === "deepseek/deepseek-chat") { shouldApplyMiddleOutTransform = true } // @ts-ignore-next-line const stream = await this.client.chat.completions.create({ - model: this.getModel().id, + model: model.id, max_tokens: maxTokens, temperature: 0, messages: openAiMessages, @@ -181,4 +191,16 @@ export class OpenRouterHandler implements ApiHandler { info: openRouterDefaultModelInfo, } } + + getAdvisorModel(): { id: string; info: ModelInfo } { + const modelId = this.options.openRouterAdvisorModelId + const modelInfo = this.options.openRouterAdvisorModelInfo + if (modelId && modelInfo) { + return { id: modelId, info: modelInfo } + } + return { + id: openRouterDefaultAdvisorModelId, + info: openRouterDefaultAdvisorModelInfo, + } + } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 54e47055f2..12b46b997b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -61,7 +61,9 @@ type GlobalStateKey = | "anthropicBaseUrl" | "azureApiVersion" | "openRouterModelId" + | "openRouterAdvisorModelId" | "openRouterModelInfo" + | "openRouterAdvisorModelInfo" | "autoApprovalSettings" | "browserSettings" @@ -354,6 +356,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { ) await this.postStateToWebview() } + if (apiConfiguration.openRouterAdvisorModelId) { + await this.updateGlobalState( + "openRouterAdvisorModelInfo", + openRouterModels[apiConfiguration.openRouterAdvisorModelId], + ) + await this.postStateToWebview() + } } }) break @@ -397,6 +406,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { azureApiVersion, openRouterModelId, openRouterModelInfo, + openRouterAdvisorModelId, + openRouterAdvisorModelInfo, } = message.apiConfiguration await this.updateGlobalState("apiProvider", apiProvider) await this.updateGlobalState("apiModelId", apiModelId) @@ -424,6 +435,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("azureApiVersion", azureApiVersion) await this.updateGlobalState("openRouterModelId", openRouterModelId) await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo) + await this.updateGlobalState("openRouterAdvisorModelId", openRouterAdvisorModelId) + await this.updateGlobalState("openRouterAdvisorModelInfo", openRouterAdvisorModelInfo) if (this.cline) { this.cline.api = buildApiHandler(message.apiConfiguration) } @@ -1030,6 +1043,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { azureApiVersion, openRouterModelId, openRouterModelInfo, + openRouterAdvisorModelId, + openRouterAdvisorModelInfo, lastShownAnnouncementId, customInstructions, taskHistory, @@ -1062,6 +1077,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("azureApiVersion") as Promise, this.getGlobalState("openRouterModelId") as Promise, this.getGlobalState("openRouterModelInfo") as Promise, + this.getGlobalState("openRouterAdvisorModelId") as Promise, + this.getGlobalState("openRouterAdvisorModelInfo") as Promise, this.getGlobalState("lastShownAnnouncementId") as Promise, this.getGlobalState("customInstructions") as Promise, this.getGlobalState("taskHistory") as Promise, @@ -1111,6 +1128,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { azureApiVersion, openRouterModelId, openRouterModelInfo, + openRouterAdvisorModelId, + openRouterAdvisorModelInfo, }, lastShownAnnouncementId, customInstructions, diff --git a/src/shared/api.ts b/src/shared/api.ts index f5ff3017fe..9a1c11d582 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -17,7 +17,9 @@ export interface ApiHandlerOptions { anthropicBaseUrl?: string openRouterApiKey?: string openRouterModelId?: string + openRouterAdvisorModelId?: string openRouterModelInfo?: ModelInfo + openRouterAdvisorModelInfo?: ModelInfo awsAccessKey?: string awsSecretKey?: string awsSessionToken?: string @@ -178,6 +180,19 @@ export const openRouterDefaultModelInfo: ModelInfo = { description: "The new Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at:\n\n- Coding: New Sonnet scores ~49% on SWE-Bench Verified, higher than the last best score, and without any fancy prompt scaffolding\n- Data science: Augments human data science expertise; navigates unstructured data while using multiple tools for insights\n- Visual processing: excelling at interpreting charts, graphs, and images, accurately transcribing text to derive insights beyond just the text alone\n- Agentic tasks: exceptional tool use, making it great at agentic tasks (i.e. complex, multi-step problem solving tasks that require engaging with other systems)\n\n#multimodal\n\n_This is a faster endpoint, made available in collaboration with Anthropic, that is self-moderated: response moderation happens on the provider's side instead of OpenRouter's. For requests that pass moderation, it's identical to the [Standard](/anthropic/claude-3.5-sonnet) variant._", } +export const openRouterDefaultAdvisorModelId = "openai/o1-preview" // will always exist in openRouterModels +export const openRouterDefaultAdvisorModelInfo: ModelInfo = { + maxTokens: 33_000, + contextWindow: 128_000, + supportsImages: true, + supportsComputerUse: false, + supportsPromptCache: false, + inputPrice: 15, + outputPrice: 60, + description: + "The latest and strongest model family from OpenAI, o1 is designed to spend more time thinking before responding.\n\nThe o1 models are optimized for math, science, programming, and other STEM-related tasks. They consistently exhibit PhD-level accuracy on benchmarks in physics, chemistry, and biology. Learn more in the [launch announcement](https://openai.com/o1).\n\nNote: This model is currently experimental and not suitable for production use-cases, and may be heavily rate-limited.", +} +export type ModelType = "base" | "advisor" // Vertex AI // https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 24e1871cfb..f242f1a868 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -11,6 +11,7 @@ import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react import { useEvent, useInterval } from "react-use" import { ApiConfiguration, + ApiProvider, ModelInfo, anthropicDefaultModelId, anthropicModels, @@ -26,6 +27,8 @@ import { openAiModelInfoSaneDefaults, openAiNativeDefaultModelId, openAiNativeModels, + openRouterDefaultAdvisorModelId, + openRouterDefaultAdvisorModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo, vertexDefaultModelId, @@ -41,15 +44,49 @@ interface ApiOptionsProps { showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string + advisorModelIdErrorMessage?: string } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { +const TabPanel = ({ children, isSelected }: { children: React.ReactNode; isSelected: boolean }) => { + if (!isSelected) return null + return
{children}
+} + +const TabButton = ({ + isSelected, + onClick, + children, +}: { + isSelected: boolean + onClick: () => void + children: React.ReactNode +}) => { + return ( + + ) +} + +const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, advisorModelIdErrorMessage }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) + const [selectedTab, setSelectedTab] = useState("base") const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => { setApiConfiguration({ @@ -713,8 +750,6 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:

)} - {selectedProvider === "openrouter" && showModelOptions && } - {selectedProvider !== "openrouter" && selectedProvider !== "openai" && selectedProvider !== "ollama" && @@ -743,7 +778,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: )} - {modelIdErrorMessage && ( + {selectedProvider !== "openrouter" && modelIdErrorMessage && (

)} + + {selectedProvider === "openrouter" && showModelOptions && ( +

+
+ setSelectedTab("base")}> + Cline Model + + setSelectedTab("advisor")}> + Advisor Model + +
+ + +

+ This is the default driver model for Cline. It will read and edit files, run commands, and more, with + your permission at each step. +

+ + {modelIdErrorMessage && ( +

+ {modelIdErrorMessage} +

+ )} +
+ + +

+ The Cline model can call this smarter, more powerful model to ask for help on planning out a task, + fixing a hard bug, and other complex problems. +

+ + {advisorModelIdErrorMessage && ( +

+ {advisorModelIdErrorMessage} +

+ )} +
+
+ )} ) } @@ -895,7 +989,13 @@ const ModelInfoSupportsItem = ({ ) -export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { +export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration): { + selectedProvider: ApiProvider + selectedModelId: string + selectedModelInfo: ModelInfo + selectedAdvisorModelId?: string + selectedAdvisorModelInfo?: ModelInfo +} { const provider = apiConfiguration?.apiProvider || "anthropic" const modelId = apiConfiguration?.apiModelId @@ -935,6 +1035,8 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { selectedProvider: provider, selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId, selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo, + selectedAdvisorModelId: apiConfiguration?.openRouterAdvisorModelId || openRouterDefaultAdvisorModelId, + selectedAdvisorModelInfo: apiConfiguration?.openRouterAdvisorModelInfo || openRouterDefaultAdvisorModelInfo, } case "openai": return { diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index cdace4472b..b8cb4992e7 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -4,15 +4,28 @@ import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from import { useRemark } from "react-remark" import { useMount } from "react-use" import styled from "styled-components" -import { openRouterDefaultModelId } from "../../../../src/shared/api" +import { + ModelType, + openRouterDefaultAdvisorModelId, + openRouterDefaultAdvisorModelInfo, + openRouterDefaultModelId, +} from "../../../../src/shared/api" import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" import { highlight } from "../history/HistoryView" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" -const OpenRouterModelPicker: React.FC = () => { +export interface OpenRouterModelPickerProps { + modelType: ModelType +} + +const OpenRouterModelPicker: React.FC = ({ modelType }) => { const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState() - const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId) + const [searchTerm, setSearchTerm] = useState( + modelType === "advisor" + ? apiConfiguration?.openRouterAdvisorModelId || openRouterDefaultAdvisorModelId + : apiConfiguration?.openRouterModelId || openRouterDefaultModelId, + ) const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) const dropdownRef = useRef(null) @@ -24,13 +37,20 @@ const OpenRouterModelPicker: React.FC = () => { // could be setting invalid model id/undefined info but validation will catch it setApiConfiguration({ ...apiConfiguration, - openRouterModelId: newModelId, - openRouterModelInfo: openRouterModels[newModelId], + ...(modelType === "advisor" + ? { + openRouterAdvisorModelId: newModelId, + openRouterAdvisorModelInfo: openRouterModels[newModelId], + } + : { + openRouterModelId: newModelId, + openRouterModelInfo: openRouterModels[newModelId], + }), }) setSearchTerm(newModelId) } - const { selectedModelId, selectedModelInfo } = useMemo(() => { + const { selectedModelId, selectedModelInfo, selectedAdvisorModelId, selectedAdvisorModelInfo } = useMemo(() => { return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) @@ -129,7 +149,7 @@ const OpenRouterModelPicker: React.FC = () => { }, [selectedIndex]) return ( - <> +
-
-
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d48a5e5084..a0363209c3 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -2,7 +2,14 @@ import React, { createContext, useCallback, useContext, useEffect, useState } fr import { useEvent } from "react-use" import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../../src/shared/AutoApprovalSettings" import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage" -import { ApiConfiguration, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../../src/shared/api" +import { + ApiConfiguration, + ModelInfo, + openRouterDefaultAdvisorModelId, + openRouterDefaultAdvisorModelInfo, + openRouterDefaultModelId, + openRouterDefaultModelInfo, +} from "../../../src/shared/api" import { findLastIndex } from "../../../src/shared/array" import { McpServer } from "../../../src/shared/mcp" import { convertTextMateToHljs } from "../utils/textMateToHljs" @@ -40,6 +47,7 @@ export const ExtensionStateContextProvider: React.FC<{ const [filePaths, setFilePaths] = useState([]) const [openRouterModels, setOpenRouterModels] = useState>({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, + [openRouterDefaultAdvisorModelId]: openRouterDefaultAdvisorModelInfo, }) const [mcpServers, setMcpServers] = useState([]) @@ -96,6 +104,7 @@ export const ExtensionStateContextProvider: React.FC<{ const updatedModels = message.openRouterModels ?? {} setOpenRouterModels({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, // in case the extension sent a model list without the default model + [openRouterDefaultAdvisorModelId]: openRouterDefaultAdvisorModelInfo, ...updatedModels, }) break diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 7dce99bebd..302c45d6a2 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -1,4 +1,4 @@ -import { ApiConfiguration, openRouterDefaultModelId } from "../../../src/shared/api" +import { ApiConfiguration, openRouterDefaultAdvisorModelId, openRouterDefaultModelId } from "../../../src/shared/api" import { ModelInfo } from "../../../src/shared/api" export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined { if (apiConfiguration) { @@ -83,3 +83,23 @@ export function validateModelId( } return undefined } + +export function validateAdvisorModelId( + apiConfiguration?: ApiConfiguration, + openRouterModels?: Record, +): string | undefined { + if (apiConfiguration) { + switch (apiConfiguration.apiProvider) { + case "openrouter": + const advisorModelId = apiConfiguration.openRouterAdvisorModelId || openRouterDefaultAdvisorModelId // in case the user hasn't changed the model id, it will be undefined by default + if (!advisorModelId) { + return "You must provide a model ID." + } + if (openRouterModels && !Object.keys(openRouterModels).includes(advisorModelId)) { + return "The model ID you provided is not available. Please choose a different model." + } + break + } + } + return undefined +}