mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7096193c75 | |||
| 728730464e | |||
| afa5492801 | |||
| 20346402d5 | |||
| e191b235ec | |||
| 9440499d27 | |||
| 29fc142506 | |||
| 076acbb61b | |||
| 2393dede2e | |||
| 9bbd7f8503 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user