Compare commits

...

10 Commits

Author SHA1 Message Date
Arafatkatze 7096193c75 Adding 1m 2026-02-16 19:39:55 -08:00
Arafatkatze 728730464e feat(cline): fetch recommended models from API endpoint 2026-02-16 17:59:46 -08:00
Arafatkatze afa5492801 Adding 1m 2026-02-16 15:35:04 -08:00
Arafatkatze 20346402d5 Adding 1m 2026-02-16 15:21:51 -08:00
Arafatkatze e191b235ec Adding 1m 2026-02-16 14:41:51 -08:00
Arafatkatze 9440499d27 Adding 1m 2026-02-16 14:41:51 -08:00
Arafatkatze 29fc142506 Adding thinking 2026-02-16 14:41:51 -08:00
Arafatkatze 076acbb61b feat: add claude-opus-4.6 model and prevent empty model cache poisoning
- Add model info for anthropic/claude-opus-4.6 with prompt caching support
- Throw error when API returns zero models to fail fast on bad responses
- Guard in-memory cache update to skip empty model maps from transient failures
- Preserve previous webview models state when refresh returns empty results
2026-02-16 14:41:51 -08:00
Arafatkatze 2393dede2e feat: add Cline as a distinct provider option for plan/act mode
Add dedicated Cline model ID and model info fields for plan and act
mode configurations across proto definitions, state management, API
handler, and webview UI. The Cline provider reuses the OpenRouter
handler but maintains separate model selection, falling back to
OpenRouter fields when Cline-specific fields are not set. Also
improves price parsing robustness in model refresh logic.
2026-02-16 14:41:51 -08:00
Arafatkatze 9bbd7f8503 Adding 1m 2026-02-16 14:41:43 -08:00
23 changed files with 1333 additions and 227 deletions
+24
View File
@@ -19,6 +19,10 @@ service ModelsService {
rpc getVsCodeLmModels(EmptyRequest) returns (VsCodeLmModelsArray);
// Refreshes and returns OpenRouter models
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Cline provider models (from Cline API)
rpc refreshClineModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns recommended and free Cline models
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
// Refreshes and returns Hugging Face models
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns OpenAI models
@@ -113,6 +117,18 @@ message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
}
message ClineRecommendedModel {
string id = 1;
string name = 2;
string description = 3;
repeated string tags = 4;
}
message ClineRecommendedModelsResponse {
repeated ClineRecommendedModel recommended = 1;
repeated ClineRecommendedModel free = 2;
}
// Request for fetching OpenAI models
message OpenAiModelsRequest {
Metadata metadata = 1;
@@ -269,6 +285,8 @@ message ModelsApiOptions {
optional OcaModelInfo plan_mode_oca_model_info = 132;
optional string plan_mode_aihubmix_model_id = 133;
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 134;
optional string plan_mode_cline_model_id = 135;
optional OpenRouterModelInfo plan_mode_cline_model_info = 136;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -306,6 +324,8 @@ message ModelsApiOptions {
optional OcaModelInfo act_mode_oca_model_info = 232;
optional string act_mode_aihubmix_model_id = 233;
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 234;
optional string act_mode_cline_model_id = 235;
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
}
// Request for updating API configuration (legacy - uses combined configuration)
@@ -619,6 +639,8 @@ message ModelsApiConfiguration {
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 137;
optional string plan_mode_nous_research_model_id = 138;
optional string gemini_plan_mode_thinking_level = 139;
optional string plan_mode_cline_model_id = 140;
optional OpenRouterModelInfo plan_mode_cline_model_info = 141;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -661,4 +683,6 @@ message ModelsApiConfiguration {
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 237;
optional string act_mode_nous_research_model_id = 238;
optional string gemini_act_mode_thinking_level = 239;
optional string act_mode_cline_model_id = 240;
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
}
+5 -1
View File
@@ -111,6 +111,7 @@ message Secrets {
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
// script to regenerate this list.
message Settings {
reserved 140; // was subagent_terminal_output_line_limit
reserved 146; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
optional string lite_llm_base_url = 1;
@@ -251,7 +252,6 @@ message Settings {
optional string default_terminal_profile = 137;
optional int32 terminal_output_line_limit = 138;
optional int32 max_consecutive_mistakes = 139;
optional int32 subagent_terminal_output_line_limit = 140;
optional bool strict_plan_mode_enabled = 141;
optional bool yolo_mode_toggled = 142;
optional bool use_auto_condense = 143;
@@ -284,6 +284,10 @@ message Settings {
optional bool auto_approve_all_toggled = 174;
map<string, string> open_ai_headers = 175;
optional bool double_check_completion_enabled = 176;
optional string plan_mode_cline_model_id = 178;
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
optional string act_mode_cline_model_id = 180;
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
}
message DictationSettings {
+68 -22
View File
@@ -26,6 +26,14 @@ function toProtoFieldName(str) {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).replace(/-/g, "_")
}
/**
* Normalize secret key names to the same identifier style used when parsing
* existing proto fields (camelCase).
*/
function normalizeSecretKeyName(str) {
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
}
// Fields that should use int64 instead of int32
const INT64_FIELDS = new Set(["planModeThinkingBudgetTokens", "actModeThinkingBudgetTokens"])
@@ -142,7 +150,7 @@ function parseSecretsKeys(sourceFile) {
const key = text.replace(/^['"]|['"]$/g, "")
// Skip prefixed keys like "cline:clineAccountId"
if (!key.includes(":")) {
keys.push(key)
keys.push(normalizeSecretKeyName(key))
}
}
@@ -231,30 +239,34 @@ function snakeToCamel(str) {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
/**
* Parse a message body from a proto file.
*/
function parseProtoMessageBody(protoContent, messageName) {
const messageRegex = new RegExp(`message\\s+${messageName}\\s*\\{([^}]*(?:\\{[^}]*\\}[^}]*)*)\\}`, "s")
const match = protoContent.match(messageRegex)
return match?.[1]
}
/**
* Parse field numbers from an existing proto message definition
* Returns a map of camelCase field names to their field numbers
*/
function parseProtoMessageFieldNumbers(protoContent, messageName) {
const fieldNumbers = {}
// Match the message block (handles single-level nesting for now)
const messageRegex = new RegExp(`message\\s+${messageName}\\s*\\{([^}]*(?:\\{[^}]*\\}[^}]*)*)\\}`, "s")
const match = protoContent.match(messageRegex)
if (!match) {
const messageBody = parseProtoMessageBody(protoContent, messageName)
if (!messageBody) {
return fieldNumbers
}
const messageBody = match[1]
// Match field definitions: optional/required/repeated type name = number;
const fieldRegex = /(?:optional|required|repeated)?\s*\w+\s+(\w+)\s*=\s*(\d+)\s*;/g
// Includes map fields such as `map<string, string> foo = 1;`
const fieldRegex = /(?:optional|required|repeated)?\s*(?:map<[^>]+>|[\w.]+)\s+(\w+)\s*=\s*(\d+)\s*;/g
const matches = messageBody.matchAll(fieldRegex)
for (const fieldMatch of matches) {
const snakeName = fieldMatch[1]
const fieldNum = parseInt(fieldMatch[2], 10)
const fieldNum = Number.parseInt(fieldMatch[2], 10)
const camelName = snakeToCamel(snakeName)
fieldNumbers[camelName] = fieldNum
}
@@ -262,6 +274,20 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
return fieldNumbers
}
/**
* Parse reserved directives from an existing proto message definition.
* Example: `reserved 140; // was subagent_terminal_output_line_limit`
*/
function parseProtoMessageReservedDirectives(protoContent, messageName) {
const messageBody = parseProtoMessageBody(protoContent, messageName)
if (!messageBody) {
return []
}
const reservedRegex = /^\s*reserved\s+[^;]+;\s*(?:\/\/.*)?$/gm
return Array.from(messageBody.matchAll(reservedRegex), (match) => match[0].trim())
}
/**
* Load field number mappings from existing proto file
*/
@@ -270,14 +296,23 @@ async function loadFieldNumbersFromProto() {
const protoContent = await fs.readFile(STATE_PROTO_PATH, "utf-8")
const secrets = parseProtoMessageFieldNumbers(protoContent, "Secrets")
const settings = parseProtoMessageFieldNumbers(protoContent, "Settings")
const secretsReserved = parseProtoMessageReservedDirectives(protoContent, "Secrets")
const settingsReserved = parseProtoMessageReservedDirectives(protoContent, "Settings")
console.log(` Found ${Object.keys(secrets).length} existing Secrets fields`)
console.log(` Found ${Object.keys(settings).length} existing Settings fields`)
console.log(` Found ${secretsReserved.length} existing Secrets reserved directives`)
console.log(` Found ${settingsReserved.length} existing Settings reserved directives`)
return { Secrets: secrets, Settings: settings }
return {
Secrets: secrets,
Settings: settings,
SecretsReserved: secretsReserved,
SettingsReserved: settingsReserved,
}
} catch {
// Proto file doesn't exist, start fresh
return { Secrets: {}, Settings: {} }
return { Secrets: {}, Settings: {}, SecretsReserved: [], SettingsReserved: [] }
}
}
@@ -315,9 +350,16 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
/**
* Generate proto message definition
*/
function generateProtoMessage(messageName, fields, fieldNumbers) {
function generateProtoMessage(messageName, fields, fieldNumbers, reservedDirectives = []) {
const lines = [`message ${messageName} {`]
for (const reservedDirective of reservedDirectives) {
lines.push(` ${reservedDirective}`)
}
if (reservedDirectives.length > 0) {
lines.push("")
}
// Sort fields by field number for consistent output
const sortedFields = [...fields].sort((a, b) => fieldNumbers[a.name] - fieldNumbers[b.name])
@@ -336,13 +378,13 @@ function generateProtoMessage(messageName, fields, fieldNumbers) {
/**
* Generate Secrets message from SECRETS_KEYS
*/
function generateSecretsMessage(secretsKeys, fieldNumbers) {
function generateSecretsMessage(secretsKeys, fieldNumbers, reservedDirectives = []) {
const fields = secretsKeys.map((key) => ({
name: key,
protoType: "string",
}))
return generateProtoMessage("Secrets", fields, fieldNumbers)
return generateProtoMessage("Secrets", fields, fieldNumbers, reservedDirectives)
}
/**
@@ -354,11 +396,10 @@ function replaceMessage(protoContent, messageName, newMessageContent) {
if (messageRegex.test(protoContent)) {
return protoContent.replace(messageRegex, newMessageContent)
} else {
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
async function main() {
@@ -391,8 +432,13 @@ async function main() {
const settingsFieldNumbers = assignFieldNumbers(settingsFields, existingFieldNumbers.Settings, 1)
// Generate messages
const secretsMessage = generateSecretsMessage(secretsKeys, secretsFieldNumbers)
const settingsMessage = generateProtoMessage("Settings", settingsFields, settingsFieldNumbers)
const secretsMessage = generateSecretsMessage(secretsKeys, secretsFieldNumbers, existingFieldNumbers.SecretsReserved)
const settingsMessage = generateProtoMessage(
"Settings",
settingsFields,
settingsFieldNumbers,
existingFieldNumbers.SettingsReserved,
)
// Read existing proto file
let protoContent = await fs.readFile(STATE_PROTO_PATH, "utf-8")
+2 -2
View File
@@ -264,8 +264,8 @@ function createHandlerForProvider(
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId,
openRouterModelInfo: mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo,
openRouterModelId: mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId,
openRouterModelInfo: mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo,
})
case "litellm":
return new LiteLlmHandler({
@@ -0,0 +1,304 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import type { ModelInfo } from "@shared/api"
import axios from "axios"
import cloneDeep from "clone-deep"
import fs from "fs/promises"
import path from "path"
import { ClineEnv } from "@/config"
import { StateManager } from "@/core/storage/StateManager"
import {
ANTHROPIC_MAX_THINKING_BUDGET,
CLAUDE_OPUS_1M_TIERS,
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
} from "@/shared/api"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from ".."
type ClineSupportedParams =
| "frequency_penalty"
| "include_reasoning"
| "logit_bias"
| "logprobs"
| "max_tokens"
| "min_p"
| "presence_penalty"
| "reasoning"
| "repetition_penalty"
| "response_format"
| "seed"
| "stop"
| "temperature"
| "tool_choice"
| "tools"
| "top_k"
| "top_logprobs"
| "top_p"
/**
* The raw model information returned by the Cline API to list models
*/
interface ClineRawModelInfo {
id: string
name: string
description: string | null
context_length: number | null
top_provider: {
max_completion_tokens: number | null
context_length: number | null
is_moderated: boolean | null
} | null
architecture: {
modality: string | string[]
input_modalities?: string[]
output_modalities?: string[]
tokenizer?: string
instruct_type?: string
} | null
pricing: {
prompt: string
completion: string
request?: string
image?: string
audio?: string
web_search?: string
internal_reasoning?: string
input_cache_read?: string
input_cache_write?: string
} | null
supports_global_endpoint?: boolean | null
tiers?: any[] | null
supported_parameters?: ClineSupportedParams[] | null
}
// Track pending refresh promise to prevent duplicate concurrent fetches
let pendingRefresh: Promise<Record<string, ModelInfo>> | null = null
/**
* Core function: Refreshes the Cline models and returns application types
* @param controller The controller instance
* @returns Record of model ID to ModelInfo (application types)
*/
export async function refreshClineModels(controller: Controller): Promise<Record<string, ModelInfo>> {
// Check in-memory cache first
const cache = StateManager.get().getModelsCache("cline")
if (cache) {
return cache
}
// If a fetch is already in progress, return the same promise
if (pendingRefresh) {
return pendingRefresh
}
// Start new fetch and track the promise
pendingRefresh = (async () => {
try {
return await fetchAndCacheClineModels(controller)
} finally {
// Clear pending promise when done (success or error)
pendingRefresh = null
}
})()
return pendingRefresh
}
async function fetchAndCacheClineModels(_controller: Controller): Promise<Record<string, ModelInfo>> {
const clineModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineModels)
let models: Record<string, ModelInfo> = {}
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/models`, getAxiosSettings())
if (response.data?.data) {
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price === undefined || price === null || price === "") {
return undefined
}
const parsedPrice = Number.parseFloat(String(price))
return Number.isNaN(parsedPrice) ? undefined : parsedPrice * 1_000_000
}
for (const rawModel of rawModels as ClineRawModelInfo[]) {
const supportThinking = rawModel.supported_parameters?.some((p) => p === "include_reasoning" || p === "reasoning")
// Handle modality which can be a string or array
const modality = rawModel.architecture?.modality
const supportsImages = Array.isArray(modality)
? modality.includes("image")
: typeof modality === "string" && modality.includes("image")
const modelInfo: ModelInfo = {
name: rawModel.name,
maxTokens: rawModel.top_provider?.max_completion_tokens ?? 0,
contextWindow: rawModel.context_length ?? 0,
supportsImages,
supportsPromptCache: false,
inputPrice: parsePrice(rawModel.pricing?.prompt) ?? 0,
outputPrice: parsePrice(rawModel.pricing?.completion) ?? 0,
cacheWritesPrice: parsePrice(rawModel.pricing?.input_cache_write),
cacheReadsPrice: parsePrice(rawModel.pricing?.input_cache_read),
description: rawModel.description ?? "",
// If thinking is supported, set maxBudget with a default value as a placeholder
// to ensure it has a valid thinkingConfig that lets the application know thinking is supported.
thinkingConfig: supportThinking ? { maxBudget: ANTHROPIC_MAX_THINKING_BUDGET } : undefined,
supportsGlobalEndpoint: rawModel.supports_global_endpoint ?? undefined,
tiers: rawModel.tiers ?? undefined,
}
// Apply model-specific overrides for known models
switch (rawModel.id) {
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
modelInfo.contextWindow = 200_000
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.5-sonnet":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-opus-4.6":
modelInfo.contextWindow = 200_000
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 6.25
modelInfo.cacheReadsPrice = 0.5
break
case "anthropic/claude-opus-4.5":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 6.25
modelInfo.cacheReadsPrice = 0.5
break
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 18.75
modelInfo.cacheReadsPrice = 1.5
break
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3.5-haiku":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 1.25
modelInfo.cacheReadsPrice = 0.1
break
case "deepseek/deepseek-chat":
modelInfo.supportsPromptCache = true
modelInfo.inputPrice = 0
modelInfo.cacheWritesPrice = 0.14
modelInfo.cacheReadsPrice = 0.014
break
case "openai/gpt-5":
case "openai/gpt-5-chat":
case "openai/gpt-5-mini":
case "openai/gpt-5-nano":
modelInfo.maxTokens = 8_192
modelInfo.contextWindow = 272_000
break
default:
// Check for cache pricing from the API response
if (rawModel.id.startsWith("openai/") || rawModel.id.startsWith("google/")) {
const cacheReadPrice = parsePrice(rawModel.pricing?.input_cache_read)
modelInfo.cacheReadsPrice = cacheReadPrice
if (cacheReadPrice !== undefined) {
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = parsePrice(rawModel.pricing?.input_cache_write)
}
}
break
}
models[rawModel.id] = modelInfo
// Add custom :1m model variant for Sonnet 4
if (rawModel.id === "anthropic/claude-sonnet-4") {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
}
// Add custom :1m model variant for Sonnet 4.5
if (rawModel.id === "anthropic/claude-sonnet-4.5") {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
}
// Add custom :1m model variant for Opus 4.6
if (rawModel.id === "anthropic/claude-opus-4.6") {
const claudeOpus1mModelInfo = cloneDeep(modelInfo)
claudeOpus1mModelInfo.contextWindow = 1_000_000
claudeOpus1mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
models[openRouterClaudeOpus461mModelId] = claudeOpus1mModelInfo
}
}
if (Object.keys(models).length === 0) {
throw new Error("No Cline models returned from API")
}
// Save models and cache them in memory
await fs.writeFile(clineModelsFilePath, JSON.stringify(models))
Logger.log("Cline models fetched and saved")
} else {
throw new Error("Invalid response data when fetching Cline models")
}
} catch (error) {
Logger.error("Error fetching Cline models:", error)
// If we failed to fetch models, try to read cached models from disk
try {
const fileExists = await fs
.access(clineModelsFilePath)
.then(() => true)
.catch(() => false)
if (fileExists) {
const fileContents = await fs.readFile(clineModelsFilePath, "utf8")
models = JSON.parse(fileContents)
Logger.log("Loaded Cline models from cache")
}
} catch (cacheError) {
Logger.error("Error reading Cline models from cache:", cacheError)
}
}
// Avoid poisoning in-memory cache with an empty model map after transient failures.
if (Object.keys(models).length > 0) {
StateManager.get().setModelsCache("cline", models)
}
return models
}
/**
* Read cached Cline models from disk
* @returns The cached models or undefined if not found
*/
export async function readClineModelsFromCache(): Promise<Record<string, ModelInfo> | undefined> {
try {
const clineModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineModels)
const fileExists = await fs
.access(clineModelsFilePath)
.then(() => true)
.catch(() => false)
if (fileExists) {
const fileContents = await fs.readFile(clineModelsFilePath, "utf8")
return JSON.parse(fileContents)
}
} catch (error) {
Logger.error("Error reading Cline models from cache:", error)
}
return undefined
}
@@ -0,0 +1,21 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
import { toProtobufModels } from "../../../shared/proto-conversions/models/typeConversion"
import type { Controller } from "../index"
import { refreshClineModels } from "./refreshClineModels"
/**
* Refreshes Cline models and returns protobuf types for gRPC
* @param controller The controller instance
* @param request Empty request (unused but required for gRPC signature)
* @returns OpenRouterCompatibleModelInfo with protobuf types (reusing the same proto type)
*/
export async function refreshClineModelsRpc(
controller: Controller,
_request: EmptyRequest,
): Promise<OpenRouterCompatibleModelInfo> {
const models = await refreshClineModels(controller)
return OpenRouterCompatibleModelInfo.create({
models: toProtobufModels(models),
})
}
@@ -0,0 +1,124 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { ClineEnv } from "@/config"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from ".."
export interface ClineRecommendedModelData {
id: string
name: string
description: string
tags: string[]
}
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModelData[]
free: ClineRecommendedModelData[]
}
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null
let inMemoryCache: { data: ClineRecommendedModelsData; timestamp: number } | null = null
function normalizeRecommendedModel(raw: unknown): ClineRecommendedModelData | null {
if (!raw || typeof raw !== "object") {
return null
}
const data = raw as Record<string, unknown>
if (typeof data.id !== "string" || data.id.length === 0) {
return null
}
return {
id: data.id,
name: typeof data.name === "string" && data.name.length > 0 ? data.name : data.id,
description: typeof data.description === "string" ? data.description : "",
tags: Array.isArray(data.tags) ? data.tags.filter((tag): tag is string => typeof tag === "string") : [],
}
}
function normalizeRecommendedModelsResponse(raw: unknown): ClineRecommendedModelsData | null {
if (!raw || typeof raw !== "object") {
return null
}
const data = raw as Record<string, unknown>
if (!Array.isArray(data.recommended) || !Array.isArray(data.free)) {
return null
}
const recommended = data.recommended
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
const free = data.free
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
return { recommended, free }
}
export async function refreshClineRecommendedModels(_controller: Controller): Promise<ClineRecommendedModelsData> {
if (inMemoryCache && Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS) {
return inMemoryCache.data
}
if (pendingRefresh) {
return pendingRefresh
}
pendingRefresh = (async () => {
try {
return await fetchAndCacheClineRecommendedModels()
} finally {
pendingRefresh = null
}
})()
return pendingRefresh
}
async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
const clineRecommendedModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineRecommendedModels)
let result: ClineRecommendedModelsData = { recommended: [], free: [] }
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/recommended-models`, getAxiosSettings())
const normalized = normalizeRecommendedModelsResponse(response.data)
if (!normalized) {
throw new Error("Invalid response data when fetching Cline recommended models")
}
result = normalized
await fs.writeFile(clineRecommendedModelsFilePath, JSON.stringify(result))
Logger.log("Cline recommended models fetched and saved")
} catch (error) {
Logger.error("Error fetching Cline recommended models:", error)
try {
const fileExists = await fs
.access(clineRecommendedModelsFilePath)
.then(() => true)
.catch(() => false)
if (fileExists) {
const fileContents = await fs.readFile(clineRecommendedModelsFilePath, "utf8")
const parsed = normalizeRecommendedModelsResponse(JSON.parse(fileContents))
if (parsed) {
result = parsed
Logger.log("Loaded Cline recommended models from cache")
}
}
} catch (cacheError) {
Logger.error("Error reading Cline recommended models from cache:", cacheError)
}
}
inMemoryCache = { data: result, timestamp: Date.now() }
return result
}
@@ -0,0 +1,29 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { ClineRecommendedModel, ClineRecommendedModelsResponse } from "@shared/proto/cline/models"
import type { Controller } from "../index"
import { refreshClineRecommendedModels } from "./refreshClineRecommendedModels"
export async function refreshClineRecommendedModelsRpc(
controller: Controller,
_request: EmptyRequest,
): Promise<ClineRecommendedModelsResponse> {
const models = await refreshClineRecommendedModels(controller)
return ClineRecommendedModelsResponse.create({
recommended: models.recommended.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
free: models.free.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
})
}
@@ -118,7 +118,7 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
return Number.parseFloat(price) * 1_000_000
}
return undefined
}
@@ -263,14 +263,19 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
models[rawModel.id] = modelInfo
// add custom :1m model variant for sonnet
if (rawModel.id === "anthropic/claude-sonnet-4" || rawModel.id === "anthropic/claude-sonnet-4.5") {
// add custom :1m model variant for sonnet 4
if (rawModel.id === "anthropic/claude-sonnet-4") {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
// sonnet 4
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
// sonnet 4.5
}
// add custom :1m model variant for sonnet 4.5
if (rawModel.id === "anthropic/claude-sonnet-4.5") {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
}
@@ -47,6 +47,9 @@ export async function updateApiConfigurationProto(
planModeOpenRouterModelInfo: protoApiConfiguration.planModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeOpenRouterModelInfo)
: undefined,
planModeClineModelInfo: protoApiConfiguration.planModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeClineModelInfo)
: undefined,
planModeOpenAiModelInfo: protoApiConfiguration.planModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeOpenAiModelInfo)
: undefined,
@@ -82,6 +85,9 @@ export async function updateApiConfigurationProto(
actModeOpenRouterModelInfo: protoApiConfiguration.actModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeOpenRouterModelInfo)
: undefined,
actModeClineModelInfo: protoApiConfiguration.actModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClineModelInfo)
: undefined,
actModeOpenAiModelInfo: protoApiConfiguration.actModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeOpenAiModelInfo)
: undefined,
+4
View File
@@ -69,6 +69,7 @@ export class StateManager {
// In-memory model info cache (not persisted to disk)
// These are for dynamic providers that fetch models from APIs
private modelInfoCache: {
clineModels: { data: Record<string, ModelInfo>; timestamp: number } | null
openRouterModels: { data: Record<string, ModelInfo>; timestamp: number } | null
groqModels: { data: Record<string, ModelInfo>; timestamp: number } | null
basetenModels: { data: Record<string, ModelInfo>; timestamp: number } | null
@@ -80,6 +81,7 @@ export class StateManager {
liteLlmModels: { data: Record<string, ModelInfo>; timestamp: number } | null
vercelModels: { data: Record<string, ModelInfo>; timestamp: number } | null
} = {
clineModels: null,
openRouterModels: null,
groqModels: null,
basetenModels: null,
@@ -416,6 +418,7 @@ export class StateManager {
*/
setModelsCache(
provider:
| "cline"
| "openRouter"
| "groq"
| "baseten"
@@ -434,6 +437,7 @@ export class StateManager {
getModelsCache(
provider:
| "cline"
| "openRouter"
| "groq"
| "baseten"
+9 -8
View File
@@ -45,6 +45,8 @@ export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
contextHistory: "context_history.json",
uiMessages: "ui_messages.json",
clineModels: "cline_models.json",
clineRecommendedModels: "cline_recommended_models.json",
openRouterModels: "openrouter_models.json",
vercelAiGatewayModels: "vercel_ai_gateway_models.json",
groqModels: "groq_models.json",
@@ -261,14 +263,13 @@ export async function getSavedClineMessages(taskId: string): Promise<ClineMessag
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.uiMessages)
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
} else {
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
}
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
return []
}
@@ -521,6 +521,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
planModeAwsBedrockCustomModelBaseId: config.planModeAwsBedrockCustomModelBaseId as string | undefined,
planModeOpenRouterModelId: config.planModeOpenRouterModelId,
planModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.planModeOpenRouterModelInfo),
planModeClineModelId: config.planModeClineModelId,
planModeClineModelInfo: convertModelInfoToProtoOpenRouter(config.planModeClineModelInfo),
planModeOpenAiModelId: config.planModeOpenAiModelId,
planModeOpenAiModelInfo: convertOpenAiCompatibleModelInfoToProto(config.planModeOpenAiModelInfo),
planModeOllamaModelId: config.planModeOllamaModelId,
@@ -563,6 +565,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
actModeAwsBedrockCustomModelBaseId: config.actModeAwsBedrockCustomModelBaseId as string | undefined,
actModeOpenRouterModelId: config.actModeOpenRouterModelId,
actModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.actModeOpenRouterModelInfo),
actModeClineModelId: config.actModeClineModelId,
actModeClineModelInfo: convertModelInfoToProtoOpenRouter(config.actModeClineModelInfo),
actModeOpenAiModelId: config.actModeOpenAiModelId,
actModeOpenAiModelInfo: convertOpenAiCompatibleModelInfoToProto(config.actModeOpenAiModelInfo),
actModeOllamaModelId: config.actModeOllamaModelId,
@@ -699,6 +703,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
planModeAwsBedrockCustomModelBaseId: protoConfig.planModeAwsBedrockCustomModelBaseId as BedrockModelId | undefined,
planModeOpenRouterModelId: protoConfig.planModeOpenRouterModelId,
planModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.planModeOpenRouterModelInfo),
planModeClineModelId: protoConfig.planModeClineModelId,
planModeClineModelInfo: convertProtoToModelInfo(protoConfig.planModeClineModelInfo),
planModeOpenAiModelId: protoConfig.planModeOpenAiModelId,
planModeOpenAiModelInfo: convertProtoToOpenAiCompatibleModelInfo(protoConfig.planModeOpenAiModelInfo),
planModeOllamaModelId: protoConfig.planModeOllamaModelId,
@@ -742,6 +748,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
actModeAwsBedrockCustomModelBaseId: protoConfig.actModeAwsBedrockCustomModelBaseId as BedrockModelId | undefined,
actModeOpenRouterModelId: protoConfig.actModeOpenRouterModelId,
actModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.actModeOpenRouterModelInfo),
actModeClineModelId: protoConfig.actModeClineModelId,
actModeClineModelInfo: convertProtoToModelInfo(protoConfig.actModeClineModelInfo),
actModeOpenAiModelId: protoConfig.actModeOpenAiModelId,
actModeOpenAiModelInfo: convertProtoToOpenAiCompatibleModelInfo(protoConfig.actModeOpenAiModelInfo),
actModeOllamaModelId: protoConfig.actModeOllamaModelId,
@@ -17,4 +17,9 @@ describe("Provider key mapping", () => {
expect(getProviderModelIdKey("openrouter", "act")).to.equal("actModeOpenRouterModelId")
expect(getProviderModelIdKey("openrouter", "plan")).to.equal("planModeOpenRouterModelId")
})
it("uses provider-specific model key behavior for Cline", () => {
expect(getProviderModelIdKey("cline", "act")).to.equal("actModeClineModelId")
expect(getProviderModelIdKey("cline", "plan")).to.equal("planModeClineModelId")
})
})
+1 -2
View File
@@ -24,10 +24,9 @@ import {
xaiDefaultModelId,
} from "../api"
// Note: "cline" provider uses the same model ID key as "openrouter"
const ProviderKeyMap: Partial<Record<ApiProvider, string>> = {
openrouter: "OpenRouterModelId",
cline: "OpenRouterModelId", // Cline provider uses OpenRouter model IDs
cline: "ClineModelId",
openai: "OpenAiModelId",
ollama: "OllamaModelId",
lmstudio: "LmStudioModelId",
+4
View File
@@ -150,6 +150,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
planModeAwsBedrockCustomModelBaseId: { default: undefined as string | undefined },
planModeOpenRouterModelId: { default: undefined as string | undefined },
planModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
planModeClineModelId: { default: undefined as string | undefined },
planModeClineModelInfo: { default: undefined as ModelInfo | undefined },
planModeOpenAiModelId: { default: undefined as string | undefined },
planModeOpenAiModelInfo: { default: undefined as OpenAiCompatibleModelInfo | undefined },
planModeOllamaModelId: { default: undefined as string | undefined },
@@ -192,6 +194,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
actModeAwsBedrockCustomModelBaseId: { default: undefined as string | undefined },
actModeOpenRouterModelId: { default: undefined as string | undefined },
actModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
actModeClineModelId: { default: undefined as string | undefined },
actModeClineModelInfo: { default: undefined as ModelInfo | undefined },
actModeOpenAiModelId: { default: undefined as string | undefined },
actModeOpenAiModelInfo: { default: undefined as OpenAiCompatibleModelInfo | undefined },
actModeOllamaModelId: { default: undefined as string | undefined },
+10 -1
View File
@@ -6,12 +6,21 @@ import type { ApiProvider } from "@shared/api"
* For OpenRouter/Vercel: excludes cline/ prefixed models
* @param modelIds Array of model IDs to filter
* @param provider The current API provider
* @param allowedFreeModelIds Optional list of Cline free model IDs to keep visible
* @returns Filtered array of model IDs
*/
export function filterOpenRouterModelIds(modelIds: string[], provider: ApiProvider): string[] {
export function filterOpenRouterModelIds(
modelIds: string[],
provider: ApiProvider,
allowedFreeModelIds: string[] = [],
): string[] {
if (provider === "cline") {
const allowedFreeIdSet = new Set(allowedFreeModelIds.map((id) => id.toLowerCase()))
// For Cline provider: exclude :free models, but keep Minimax and Devstral models
return modelIds.filter((id) => {
if (allowedFreeIdSet.has(id.toLowerCase())) {
return true
}
// Keep all Minimax and devstral models regardless of :free suffix
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("devstral-2512")) {
return true
@@ -0,0 +1,592 @@
import { CLAUDE_SONNET_1M_SUFFIX, openRouterDefaultModelId } from "@shared/api"
import { StringRequest } from "@shared/proto/cline/common"
import type { Mode } from "@shared/storage/types"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import type React from "react"
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
import { useMount } from "react-use"
import styled from "styled-components"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { StateServiceClient } from "@/services/grpc-client"
import { highlight } from "../history/HistoryView"
import { ContextWindowSwitcher } from "./common/ContextWindowSwitcher"
import { ModelInfoView } from "./common/ModelInfoView"
import FeaturedModelCard from "./FeaturedModelCard"
import ReasoningEffortSelector from "./ReasoningEffortSelector"
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
import {
filterOpenRouterModelIds,
getModeSpecificFields,
normalizeApiConfiguration,
supportsReasoningEffortForModelId,
} from "./utils/providerUtils"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
// Star icon for favorites
const StarIcon = ({ isFavorite, onClick }: { isFavorite: boolean; onClick: (e: React.MouseEvent) => void }) => {
return (
<div
onClick={onClick}
style={{
cursor: "pointer",
color: isFavorite ? "var(--vscode-terminal-ansiBlue)" : "var(--vscode-descriptionForeground)",
marginLeft: "8px",
fontSize: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
userSelect: "none",
WebkitUserSelect: "none",
}}>
{isFavorite ? "★" : "☆"}
</div>
)
}
export interface ClineModelPickerProps {
isPopup?: boolean
currentMode: Mode
showProviderRouting?: boolean
initialTab?: "recommended" | "free"
}
interface FeaturedModelCardEntry {
id: string
description: string
label: string
}
const RECOMMENDED_MODELS_FALLBACK: FeaturedModelCardEntry[] = [
{
id: "anthropic/claude-sonnet-4.5",
description: "Best balance of speed, cost, and quality",
label: "BEST",
},
{
id: "minimax/minimax-m2.5",
description: "Great coding capability and subagent use",
label: "HOT",
},
{
id: "anthropic/claude-opus-4.6",
description: "Most intelligent model for agents and coding",
label: "NEW",
},
{
id: "openai/gpt-5.2-codex",
description: "OpenAI's latest with strong coding abilities",
label: "HOT",
},
{
id: "google/gemini-3-pro-preview",
description: "1M context window for large codebases",
label: "1M CTX",
},
]
const FREE_MODELS_FALLBACK: FeaturedModelCardEntry[] = [
{
id: "minimax/minimax-m2.5",
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
label: "FREE",
},
{
id: "kwaipilot/kat-coder-pro",
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
label: "FREE",
},
{
id: "arcee-ai/trinity-large-preview:free",
description: "Arcee AI's advanced large preview model in the Trinity series",
label: "FREE",
},
]
const FREE_CLINE_MODELS_FALLBACK = FREE_MODELS_FALLBACK.map((m) => m.id)
const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMode, showProviderRouting, initialTab }) => {
const { handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
const {
apiConfiguration,
favoritedModelIds,
clineModels,
clineRecommendedModels,
clineFreeModels,
refreshClineModels,
refreshClineRecommendedModels,
} = useExtensionState()
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
const [searchTerm, setSearchTerm] = useState(modeFields.clineModelId || openRouterDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const initialFreeModelIds = clineFreeModels.length > 0 ? clineFreeModels.map((model) => model.id) : FREE_CLINE_MODELS_FALLBACK
const [activeTab, setActiveTab] = useState<"recommended" | "free">(() => {
if (initialTab) {
return initialTab
}
const currentModelId = modeFields.clineModelId || openRouterDefaultModelId
return initialFreeModelIds.includes(currentModelId) ? "free" : "recommended"
})
const recommendedModels = useMemo<FeaturedModelCardEntry[]>(() => {
if (clineRecommendedModels.length === 0) {
return RECOMMENDED_MODELS_FALLBACK
}
return clineRecommendedModels.map((model) => ({
id: model.id,
description: model.description || "Recommended model",
label: model.tags[0]?.toUpperCase() || "RECOMMENDED",
}))
}, [clineRecommendedModels])
const freeModels = useMemo<FeaturedModelCardEntry[]>(() => {
if (clineFreeModels.length === 0) {
return FREE_MODELS_FALLBACK
}
return clineFreeModels.map((model) => ({
id: model.id,
description: model.description || "Free model",
label: model.tags[0]?.toUpperCase() || "FREE",
}))
}, [clineFreeModels])
const freeClineModelIds = useMemo(() => freeModels.map((model) => model.id), [freeModels])
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab)
}
}, [initialTab])
useEffect(() => {
if (initialTab) {
return
}
const currentModelId = modeFields.clineModelId || openRouterDefaultModelId
setActiveTab(freeClineModelIds.includes(currentModelId) ? "free" : "recommended")
}, [modeFields.clineModelId, freeClineModelIds, initialTab])
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
const handleModelChange = (newModelId: string) => {
setSearchTerm(newModelId)
handleModeFieldsChange(
{
clineModelId: { plan: "planModeClineModelId", act: "actModeClineModelId" },
clineModelInfo: { plan: "planModeClineModelInfo", act: "actModeClineModelInfo" },
},
{
clineModelId: newModelId,
clineModelInfo: clineModels?.[newModelId],
},
currentMode,
)
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
const selected = normalizeApiConfiguration(apiConfiguration, currentMode)
if (freeClineModelIds.includes(selected.selectedModelId)) {
return {
...selected,
selectedModelInfo: {
...selected.selectedModelInfo,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
},
}
}
return selected
}, [apiConfiguration, currentMode, freeClineModelIds])
useMount(() => {
refreshClineModels()
refreshClineRecommendedModels()
})
// Sync external changes when the modelId changes
useEffect(() => {
const currentModelId = modeFields.clineModelId || openRouterDefaultModelId
setSearchTerm(currentModelId)
}, [modeFields.clineModelId])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownVisible(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
const modelIds = useMemo(() => {
const unfilteredModelIds = Object.keys(clineModels ?? {}).sort((a, b) => a.localeCompare(b))
return filterOpenRouterModelIds(unfilteredModelIds, "cline", freeClineModelIds)
}, [clineModels, freeClineModelIds])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
id,
html: id,
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
// First, get all favorited models
const favoritedModels = searchableItems.filter((item) => favoritedModelIds.includes(item.id))
// Then get search results for non-favorited models
const searchResults = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight").filter((item) => !favoritedModelIds.includes(item.id))
: searchableItems.filter((item) => !favoritedModelIds.includes(item.id))
// Combine favorited models with search results
return [...favoritedModels, ...searchResults]
}, [searchableItems, searchTerm, fuse, favoritedModelIds])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) {
return
}
switch (event.key) {
case "ArrowDown":
event.preventDefault()
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
break
case "ArrowUp":
event.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case "Enter":
event.preventDefault()
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
handleModelChange(modelSearchResults[selectedIndex].id)
setIsDropdownVisible(false)
} else {
handleModelChange(searchTerm)
setIsDropdownVisible(false)
}
break
case "Escape":
setIsDropdownVisible(false)
setSelectedIndex(-1)
break
}
}
const hasInfo = useMemo(() => {
try {
if (searchTerm.startsWith("@preset/")) {
return false
}
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
} catch {
return false
}
}, [modelIds, searchTerm])
useEffect(() => {
setSelectedIndex(-1)
if (dropdownListRef.current) {
dropdownListRef.current.scrollTop = 0
}
}, [searchTerm])
useEffect(() => {
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
block: "nearest",
behavior: "smooth",
})
}
}, [selectedIndex])
const selectedModelIdLower = selectedModelId?.toLowerCase() || ""
const showReasoningEffort = useMemo(() => supportsReasoningEffortForModelId(selectedModelId), [selectedModelId])
const showBudgetSlider = useMemo(() => {
if (showReasoningEffort) {
return false
}
return (
Object.entries(clineModels ?? {})?.some(([id, m]) => id === selectedModelId && m.thinkingConfig) ||
selectedModelIdLower.includes("claude-opus-4.6") ||
selectedModelIdLower.includes("claude-haiku-4.5") ||
selectedModelIdLower.includes("claude-4.5-haiku") ||
selectedModelIdLower.includes("claude-sonnet-4.5") ||
selectedModelIdLower.includes("claude-sonnet-4") ||
selectedModelIdLower.includes("claude-opus-4.1") ||
selectedModelIdLower.includes("claude-opus-4") ||
selectedModelIdLower.includes("claude-opus-4.5") ||
selectedModelIdLower.includes("claude-3-7-sonnet") ||
selectedModelIdLower.includes("claude-3.7-sonnet") ||
selectedModelIdLower.includes("claude-3.7-sonnet:thinking")
)
}, [clineModels, selectedModelId, selectedModelIdLower, showReasoningEffort])
return (
<div style={{ width: "100%", paddingBottom: 2 }}>
<style>
{`
.model-item-highlight {
background-color: var(--vscode-editor-findMatchHighlightBackground);
color: inherit;
}
`}
</style>
<div style={{ display: "flex", flexDirection: "column" }}>
<label htmlFor="model-search">
<span style={{ fontWeight: 500 }}>Model</span>
</label>
<>
{/* Tabs */}
<TabsContainer style={{ marginTop: 4 }}>
<Tab active={activeTab === "recommended"} onClick={() => setActiveTab("recommended")}>
Recommended
</Tab>
<Tab active={activeTab === "free"} onClick={() => setActiveTab("free")}>
Free
</Tab>
</TabsContainer>
{/* Model Cards */}
<div style={{ marginBottom: "6px" }}>
{activeTab === "recommended" &&
recommendedModels.map((model) => (
<FeaturedModelCard
description={model.description}
isSelected={selectedModelId === model.id}
key={model.id}
label={model.label}
modelId={model.id}
onClick={() => {
handleModelChange(model.id)
setIsDropdownVisible(false)
}}
/>
))}
{activeTab === "free" &&
freeModels.map((model) => (
<FeaturedModelCard
description={model.description}
isSelected={selectedModelId === model.id}
key={model.id}
label={model.label}
modelId={model.id}
onClick={() => {
handleModelChange(model.id)
setIsDropdownVisible(false)
}}
/>
))}
</div>
</>
<DropdownWrapper ref={dropdownRef}>
<VSCodeTextField
id="model-search"
onBlur={() => {
if (searchTerm !== selectedModelId) {
handleModelChange(searchTerm)
}
}}
onFocus={() => setIsDropdownVisible(true)}
onInput={(e) => {
setSearchTerm((e.target as HTMLInputElement)?.value.toLowerCase() || "")
setIsDropdownVisible(true)
}}
onKeyDown={handleKeyDown}
placeholder="Search and select a model..."
role="combobox"
style={{
width: "100%",
zIndex: CLINE_MODEL_PICKER_Z_INDEX,
position: "relative",
}}
value={searchTerm}>
{searchTerm && (
<div
aria-label="Clear search"
className="input-icon-button codicon codicon-close"
onClick={() => {
setSearchTerm("")
setIsDropdownVisible(true)
}}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList ref={dropdownListRef} role="listbox">
{modelSearchResults.map((item, index) => {
const isFavorite = (favoritedModelIds || []).includes(item.id)
return (
<DropdownItem
isSelected={index === selectedIndex}
key={item.id}
onClick={() => {
handleModelChange(item.id)
setIsDropdownVisible(false)
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => (itemRefs.current[index] = el)}
role="option">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span dangerouslySetInnerHTML={{ __html: item.html }} />
<StarIcon
isFavorite={isFavorite}
onClick={(e) => {
e.stopPropagation()
StateServiceClient.toggleFavoriteModel(
StringRequest.create({ value: item.id }),
).catch((error) => console.error("Failed to toggle favorite model:", error))
}}
/>
</div>
</DropdownItem>
)
})}
</DropdownList>
)}
</DropdownWrapper>
{/* Context window switcher for Claude Opus 4.6 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.6${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-opus-4.6"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Sonnet 4.5 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-sonnet-4.5${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-sonnet-4.5"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Sonnet 4 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-sonnet-4${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-sonnet-4"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
</div>
{hasInfo ? (
<>
{showBudgetSlider && <ThinkingBudgetSlider currentMode={currentMode} />}
{showReasoningEffort && <ReasoningEffortSelector currentMode={currentMode} />}
<ModelInfoView
isPopup={isPopup}
modelInfo={selectedModelInfo}
onProviderSortingChange={(value) => handleFieldChange("openRouterProviderSorting", value)}
providerSorting={apiConfiguration?.openRouterProviderSorting}
selectedModelId={selectedModelId}
showProviderRouting={showProviderRouting}
/>
</>
) : (
<p
style={{
fontSize: "12px",
marginTop: 0,
color: "var(--vscode-descriptionForeground)",
}}>
The extension automatically fetches the latest Cline model list. If you're unsure which model to choose, Cline
works best with <strong>anthropic/claude-sonnet-4.5</strong>.
</p>
)}
</div>
)
}
export default ClineModelPicker
const DropdownWrapper = styled.div`
position: relative;
width: 100%;
`
const CLINE_MODEL_PICKER_Z_INDEX = 1_000
const DropdownList = styled.div`
position: absolute;
top: calc(100% - 3px);
left: 0;
width: calc(100% - 2px);
max-height: 200px;
overflow-y: auto;
background-color: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-list-activeSelectionBackground);
z-index: ${CLINE_MODEL_PICKER_Z_INDEX - 1};
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
`
const DropdownItem = styled.div<{ isSelected: boolean }>`
padding: 5px 10px;
cursor: pointer;
word-break: break-all;
white-space: normal;
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
&:hover {
background-color: var(--vscode-list-activeSelectionBackground);
}
`
const TabsContainer = styled.div`
display: flex;
gap: 0;
margin-bottom: 12px;
border-bottom: 1px solid #333;
`
const Tab = styled.div<{ active: boolean }>`
padding: 8px 16px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: ${({ active }) => (active ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
border-bottom: 2px solid ${({ active }) => (active ? "var(--vscode-textLink-foreground)" : "transparent")};
transition: all 0.15s ease;
&:hover {
color: var(--vscode-foreground);
}
`
@@ -12,7 +12,6 @@ import { StateServiceClient } from "@/services/grpc-client"
import { highlight } from "../history/HistoryView"
import { ContextWindowSwitcher } from "./common/ContextWindowSwitcher"
import { ModelInfoView } from "./common/ModelInfoView"
import FeaturedModelCard from "./FeaturedModelCard"
import ReasoningEffortSelector from "./ReasoningEffortSelector"
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
import {
@@ -48,96 +47,20 @@ export interface OpenRouterModelPickerProps {
isPopup?: boolean
currentMode: Mode
showProviderRouting?: boolean
initialTab?: "recommended" | "free"
}
// Featured models for Cline provider organized by tabs
export const recommendedModels = [
{
id: "anthropic/claude-sonnet-4.5",
description: "Best balance of speed, cost, and quality",
label: "BEST",
},
{
id: "minimax/minimax-m2.5",
description: "Great coding capability and subagent use",
label: "HOT",
},
{
id: "anthropic/claude-opus-4.6",
description: "Most intelligent model for agents and coding",
label: "NEW",
},
{
id: "openai/gpt-5.2-codex",
description: "OpenAI's latest with strong coding abilities",
label: "HOT",
},
{
id: "google/gemini-3-pro-preview",
description: "1M context window for large codebases",
label: "1M CTX",
},
]
export const freeModels = [
{
id: "minimax/minimax-m2.5",
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
label: "FREE",
},
{
id: "z-ai/glm-5",
description: "Z.AI's latest GLM 5 model with strong coding and agent performance",
label: "FREE",
},
{
id: "kwaipilot/kat-coder-pro",
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
label: "FREE",
},
{
id: "arcee-ai/trinity-large-preview:free",
description: "Arcee AI's advanced large preview model in the Trinity series",
label: "FREE",
},
]
const FREE_CLINE_MODELS = freeModels.map((m) => m.id)
const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
isPopup,
currentMode,
showProviderRouting,
initialTab,
}) => {
const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup, currentMode, showProviderRouting }) => {
const { handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
const { apiConfiguration, favoritedModelIds, openRouterModels, refreshOpenRouterModels } = useExtensionState()
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
const [searchTerm, setSearchTerm] = useState(modeFields.openRouterModelId || openRouterDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const [activeTab, setActiveTab] = useState<"recommended" | "free">(() => {
if (initialTab) {
return initialTab
}
const currentModelId = modeFields.openRouterModelId || openRouterDefaultModelId
return freeModels.some((m) => m.id === currentModelId) ? "free" : "recommended"
})
// If a caller wants to deep-link to the Free tab (or Recommended), honor that.
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab)
}
}, [initialTab])
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
const handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it
setSearchTerm(newModelId)
handleModeFieldsChange(
@@ -154,22 +77,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
const selected = normalizeApiConfiguration(apiConfiguration, currentMode)
const isCline = selected.selectedProvider === "cline"
// Makes sure "Free" featured models have $0 pricing for Cline provider
if (isCline && FREE_CLINE_MODELS.includes(selected.selectedModelId)) {
return {
...selected,
selectedModelInfo: {
...selected.selectedModelInfo,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
},
}
}
return selected
return normalizeApiConfiguration(apiConfiguration, currentMode)
}, [apiConfiguration, currentMode])
useMount(refreshOpenRouterModels)
@@ -195,8 +103,8 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
const modelIds = useMemo(() => {
const unfilteredModelIds = Object.keys(openRouterModels).sort((a, b) => a.localeCompare(b))
return filterOpenRouterModelIds(unfilteredModelIds, modeFields.apiProvider || "openrouter")
}, [openRouterModels, modeFields.apiProvider])
return filterOpenRouterModelIds(unfilteredModelIds, "openrouter")
}, [openRouterModels])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
@@ -218,8 +126,6 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
}, [searchableItems])
const modelSearchResults = useMemo(() => {
// IMPORTANT: highlightjs has a bug where if you use sort/localCompare - "// results.sort((a, b) => a.id.localeCompare(b.id)) ...sorting like this causes ids in objects to be reordered and mismatched"
// First, get all favorited models
const favoritedModels = searchableItems.filter((item) => favoritedModelIds.includes(item.id))
@@ -333,52 +239,6 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
<span style={{ fontWeight: 500 }}>Model</span>
</label>
{modeFields.apiProvider === "cline" && (
<>
{/* Tabs */}
<TabsContainer style={{ marginTop: 4 }}>
<Tab active={activeTab === "recommended"} onClick={() => setActiveTab("recommended")}>
Recommended
</Tab>
<Tab active={activeTab === "free"} onClick={() => setActiveTab("free")}>
Free
</Tab>
</TabsContainer>
{/* Model Cards */}
<div style={{ marginBottom: "6px" }}>
{activeTab === "recommended" &&
recommendedModels.map((model) => (
<FeaturedModelCard
description={model.description}
isSelected={selectedModelId === model.id}
key={model.id}
label={model.label}
modelId={model.id}
onClick={() => {
handleModelChange(model.id)
setIsDropdownVisible(false)
}}
/>
))}
{activeTab === "free" &&
freeModels.map((model) => (
<FeaturedModelCard
description={model.description}
isSelected={selectedModelId === model.id}
key={model.id}
label={model.label}
modelId={model.id}
onClick={() => {
handleModelChange(model.id)
setIsDropdownVisible(false)
}}
/>
))}
</div>
</>
)}
<DropdownWrapper ref={dropdownRef}>
<VSCodeTextField
id="model-search"
@@ -568,26 +428,3 @@ const DropdownItem = styled.div<{ isSelected: boolean }>`
background-color: var(--vscode-list-activeSelectionBackground);
}
`
// Tabs
const TabsContainer = styled.div`
display: flex;
gap: 0;
margin-bottom: 12px;
border-bottom: 1px solid #333;
`
const Tab = styled.div<{ active: boolean }>`
padding: 8px 16px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: ${({ active }) => (active ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
border-bottom: 2px solid ${({ active }) => (active ? "var(--vscode-textLink-foreground)" : "transparent")};
transition: all 0.15s ease;
&:hover {
color: var(--vscode-foreground);
}
`
@@ -1,6 +1,6 @@
import { Mode } from "@shared/storage/types"
import { ClineAccountInfoCard } from "../ClineAccountInfoCard"
import OpenRouterModelPicker from "../OpenRouterModelPicker"
import ClineModelPicker from "../ClineModelPicker"
/**
* Props for the ClineProvider component
@@ -25,8 +25,7 @@ export const ClineProvider = ({ showModelOptions, isPopup, currentMode, initialM
{showModelOptions && (
<>
{/* OpenRouter Model Picker - includes Provider Routing in Advanced section */}
<OpenRouterModelPicker
<ClineModelPicker
currentMode={currentMode}
initialTab={initialModelTab}
isPopup={isPopup}
@@ -270,18 +270,16 @@ export function normalizeApiConfiguration(
selectedModelInfo: requestyModelInfo || requestyDefaultModelInfo,
}
case "cline":
const clineOpenRouterModelId =
(currentMode === "plan"
? apiConfiguration?.planModeOpenRouterModelId
: apiConfiguration?.actModeOpenRouterModelId) || openRouterDefaultModelId
const clineOpenRouterModelInfo =
(currentMode === "plan"
? apiConfiguration?.planModeOpenRouterModelInfo
: apiConfiguration?.actModeOpenRouterModelInfo) || openRouterDefaultModelInfo
const clineModelId =
(currentMode === "plan" ? apiConfiguration?.planModeClineModelId : apiConfiguration?.actModeClineModelId) ||
openRouterDefaultModelId
const clineModelInfo =
(currentMode === "plan" ? apiConfiguration?.planModeClineModelInfo : apiConfiguration?.actModeClineModelInfo) ||
openRouterDefaultModelInfo
return {
selectedProvider: provider,
selectedModelId: clineOpenRouterModelId,
selectedModelInfo: clineOpenRouterModelInfo,
selectedModelId: clineModelId,
selectedModelInfo: clineModelInfo,
}
case "openai":
const openAiModelId =
@@ -519,6 +517,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
requestyModelId: undefined,
openAiModelId: undefined,
openRouterModelId: undefined,
clineModelId: undefined,
groqModelId: undefined,
basetenModelId: undefined,
huggingFaceModelId: undefined,
@@ -532,6 +531,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
openAiModelInfo: undefined,
liteLlmModelInfo: undefined,
openRouterModelInfo: undefined,
clineModelInfo: undefined,
requestyModelInfo: undefined,
groqModelInfo: undefined,
basetenModelInfo: undefined,
@@ -567,6 +567,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
openAiModelId: mode === "plan" ? apiConfiguration.planModeOpenAiModelId : apiConfiguration.actModeOpenAiModelId,
openRouterModelId:
mode === "plan" ? apiConfiguration.planModeOpenRouterModelId : apiConfiguration.actModeOpenRouterModelId,
clineModelId: mode === "plan" ? apiConfiguration.planModeClineModelId : apiConfiguration.actModeClineModelId,
groqModelId: mode === "plan" ? apiConfiguration.planModeGroqModelId : apiConfiguration.actModeGroqModelId,
basetenModelId: mode === "plan" ? apiConfiguration.planModeBasetenModelId : apiConfiguration.actModeBasetenModelId,
huggingFaceModelId:
@@ -586,6 +587,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
liteLlmModelInfo: mode === "plan" ? apiConfiguration.planModeLiteLlmModelInfo : apiConfiguration.actModeLiteLlmModelInfo,
openRouterModelInfo:
mode === "plan" ? apiConfiguration.planModeOpenRouterModelInfo : apiConfiguration.actModeOpenRouterModelInfo,
clineModelInfo: mode === "plan" ? apiConfiguration.planModeClineModelInfo : apiConfiguration.actModeClineModelInfo,
requestyModelInfo:
mode === "plan" ? apiConfiguration.planModeRequestyModelInfo : apiConfiguration.actModeRequestyModelInfo,
groqModelInfo: mode === "plan" ? apiConfiguration.planModeGroqModelInfo : apiConfiguration.actModeGroqModelInfo,
@@ -661,13 +663,19 @@ export async function syncModeConfigurations(
// Handle provider-specific fields
switch (apiProvider) {
case "openrouter":
case "cline":
updates.planModeOpenRouterModelId = sourceFields.openRouterModelId
updates.actModeOpenRouterModelId = sourceFields.openRouterModelId
updates.planModeOpenRouterModelInfo = sourceFields.openRouterModelInfo
updates.actModeOpenRouterModelInfo = sourceFields.openRouterModelInfo
break
case "cline":
updates.planModeClineModelId = sourceFields.clineModelId
updates.actModeClineModelId = sourceFields.clineModelId
updates.planModeClineModelInfo = sourceFields.clineModelInfo
updates.actModeClineModelInfo = sourceFields.clineModelInfo
break
case "requesty":
updates.planModeRequestyModelId = sourceFields.requestyModelId
updates.actModeRequestyModelId = sourceFields.requestyModelId
@@ -822,12 +830,21 @@ export async function syncModeConfigurations(
* For OpenRouter/Vercel: excludes cline/ prefixed models
* @param modelIds Array of model IDs to filter
* @param provider The current API provider
* @param allowedFreeModelIds Optional list of Cline free model IDs to keep visible
* @returns Filtered array of model IDs
*/
export function filterOpenRouterModelIds(modelIds: string[], provider: ApiProvider): string[] {
export function filterOpenRouterModelIds(
modelIds: string[],
provider: ApiProvider,
allowedFreeModelIds: string[] = [],
): string[] {
if (provider === "cline") {
// For Cline provider: exclude :free models, but keep Minimax models
const allowedFreeIdSet = new Set(allowedFreeModelIds.map((id) => id.toLowerCase()))
// For Cline provider: exclude :free models, but keep known special cases and explicitly allowed free IDs
return modelIds.filter((id) => {
if (allowedFreeIdSet.has(id.toLowerCase())) {
return true
}
// Keep all Minimax and devstral models regardless of :free suffix
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("arcee-ai/trinity-large")) {
return true
@@ -7,7 +7,11 @@ import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "@shared/McpDisplayMode"
import type { UserInfo } from "@shared/proto/cline/account"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
import type {
ClineRecommendedModel,
ClineRecommendedModelsResponse,
OpenRouterCompatibleModelInfo,
} from "@shared/proto/cline/models"
import { OnboardingModelGroup, type TerminalProfile } from "@shared/proto/cline/state"
import { convertProtoToClineMessage } from "@shared/proto-conversions/cline-message"
import { convertProtoMcpServersToMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
@@ -33,6 +37,9 @@ export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
showWelcome: boolean
onboardingModels: OnboardingModelGroup | undefined
clineModels: Record<string, ModelInfo> | null
clineRecommendedModels: ClineRecommendedModel[]
clineFreeModels: ClineRecommendedModel[]
openRouterModels: Record<string, ModelInfo>
vercelAiGatewayModels: Record<string, ModelInfo>
hicapModels: Record<string, ModelInfo>
@@ -89,6 +96,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setOnboardingModels: (value: OnboardingModelGroup | undefined) => void
// Refresh functions
refreshClineModels: () => void
refreshClineRecommendedModels: () => void
refreshOpenRouterModels: () => void
refreshVercelAiGatewayModels: () => void
refreshHicapModels: () => void
@@ -297,6 +306,9 @@ export const ExtensionStateContextProvider: React.FC<{
const [showWelcome, setShowWelcome] = useState(false)
const [onboardingModels, setOnboardingModels] = useState<OnboardingModelGroup | undefined>(undefined)
const [clineModels, setClineModels] = useState<Record<string, ModelInfo> | null>(null)
const [clineRecommendedModels, setClineRecommendedModels] = useState<ClineRecommendedModel[]>([])
const [clineFreeModels, setClineFreeModels] = useState<ClineRecommendedModel[]>([])
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
@@ -763,11 +775,53 @@ export const ExtensionStateContextProvider: React.FC<{
refreshLiteLlmModels,
])
// Refresh Cline models function
const refreshClineModels = useCallback(() => {
ModelsServiceClient.refreshClineModelsRpc(EmptyRequest.create({}))
.then((response: OpenRouterCompatibleModelInfo) => {
const models = fromProtobufModels(response.models)
setClineModels((prev) => (Object.keys(models).length > 0 ? models : (prev ?? null)))
})
.catch((error: Error) => console.error("Failed to refresh Cline models:", error))
}, [])
const refreshClineRecommendedModels = useCallback(() => {
ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
.then((response: ClineRecommendedModelsResponse) => {
setClineRecommendedModels(response.recommended ?? [])
setClineFreeModels(response.free ?? [])
})
.catch((error: Error) => console.error("Failed to refresh Cline recommended models:", error))
}, [])
// Auto-refresh Cline models when provider is cline
useEffect(() => {
const hasClineProvider =
state.apiConfiguration?.actModeApiProvider === "cline" || state.apiConfiguration?.planModeApiProvider === "cline"
if (hasClineProvider && clineModels === null) {
refreshClineModels()
}
if (hasClineProvider && clineRecommendedModels.length === 0 && clineFreeModels.length === 0) {
refreshClineRecommendedModels()
}
}, [
state.apiConfiguration?.actModeApiProvider,
state.apiConfiguration?.planModeApiProvider,
clineModels,
clineRecommendedModels.length,
clineFreeModels.length,
refreshClineModels,
refreshClineRecommendedModels,
])
const contextValue: ExtensionStateContextType = {
...state,
didHydrateState,
showWelcome,
onboardingModels,
clineModels,
clineRecommendedModels,
clineFreeModels,
openRouterModels,
vercelAiGatewayModels,
hicapModels,
@@ -890,6 +944,8 @@ export const ExtensionStateContextProvider: React.FC<{
})),
setMcpTab,
setTotalTasksSize,
refreshClineModels,
refreshClineRecommendedModels,
refreshOpenRouterModels,
refreshVercelAiGatewayModels,
refreshHicapModels,
+14 -2
View File
@@ -181,12 +181,12 @@ export function validateModelId(
currentMode: Mode,
apiConfiguration?: ApiConfiguration,
openRouterModels?: Record<string, ModelInfo>,
clineModels?: Record<string, ModelInfo>,
): string | undefined {
if (apiConfiguration) {
const { apiProvider, openRouterModelId } = getModeSpecificFields(apiConfiguration, currentMode)
const { apiProvider, openRouterModelId, clineModelId } = getModeSpecificFields(apiConfiguration, currentMode)
switch (apiProvider) {
case "openrouter":
case "cline":
const modelId = openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!modelId) {
return "You must provide a model ID."
@@ -199,6 +199,18 @@ export function validateModelId(
return "The model ID you provided is not available. Please choose a different model."
}
break
case "cline":
const clineResolvedModelId = clineModelId || openRouterDefaultModelId
if (!clineResolvedModelId) {
return "You must provide a model ID."
}
if (clineResolvedModelId.startsWith("@preset/")) {
break
}
if (clineModels && !Object.keys(clineModels).includes(clineResolvedModelId)) {
return "The model ID you provided is not available. Please choose a different model."
}
break
}
}
return undefined