Compare commits

...

14 Commits

Author SHA1 Message Date
John Choi dd2cfdd37d feat(onboarding): nudge + Cline Pass subscribe link in signup flow
Strengthen the Cline Pass onboarding option with a 'Recommended' copy
nudge (text + ordering, no badge/paid-default), and add an additive
subscribe affordance on the post-signup 'Almost there!' step that links
to {appBaseUrl}/dashboard/plan. Client-only: reuses existing appBaseUrl
from useClineAuth and the existing dashboard subscribe page; no backend
change. Shown only when the user selects Cline Pass; existing flow and
other user-types are unchanged.
2026-06-16 18:54:53 -07:00
John Choi 16a00b1bb2 feat(onboarding): add Cline Pass as optional user-type in signup flow
Surface Cline Pass as a recommended-but-optional onboarding choice gated
behind the ext-cline-pass feature flag, alongside Free / Frontier / BYOK.
When selected, signup provisions the cline-pass provider and ClinePass
model fields; price info is hidden since cost is covered by the
subscription. Falls back cleanly to the existing flow when the flag is
off. Adds unit tests for the new helpers.
2026-06-16 11:11:05 -07:00
BarreiroT 496151de4a Merge remote-tracking branch 'origin/main' into cline-pass-on-the-extension 2026-06-15 17:58:41 -03:00
BarreiroT 9a51359f5b Merge branch 'main' into cline-pass-on-the-extension 2026-06-15 17:53:32 -03:00
BarreiroT 39a21d9480 Hide price info for clinePass models 2026-06-15 15:48:28 -03:00
BarreiroT 17c5cc0062 Fix tests 2026-06-15 15:42:11 -03:00
BarreiroT dfc9bb8493 Use the right model info 2026-06-15 15:18:42 -03:00
BarreiroT 18b06dbc6a fix type error 2026-06-15 11:30:26 -03:00
BarreiroT 6510f6898c Extend flag usage 2026-06-15 01:08:02 -03:00
BarreiroT ce80bdfcbc Add feature flag to the api options 2026-06-15 00:41:50 -03:00
BarreiroT 7c3b154ae2 Merge branch 'main' into cline-pass-on-the-extension 2026-06-15 00:32:51 -03:00
BarreiroT f14b0c9c00 ClinePass specific options 2026-06-15 00:21:37 -03:00
BarreiroT 951bfe97a7 Fix the model picker 2026-06-14 23:52:48 -03:00
BarreiroT 3d0968f2e0 Add Cline Pass provider to VS Code extension 2026-06-12 23:37:21 -03:00
31 changed files with 870 additions and 223 deletions
+10
View File
@@ -127,6 +127,7 @@ message ClineRecommendedModel {
message ClineRecommendedModelsResponse {
repeated ClineRecommendedModel recommended = 1;
repeated ClineRecommendedModel free = 2;
repeated ClineRecommendedModel cline_pass = 3;
}
// Request for fetching OpenAI models
@@ -287,6 +288,8 @@ message ModelsApiOptions {
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 134;
optional string plan_mode_cline_model_id = 135;
optional OpenRouterModelInfo plan_mode_cline_model_info = 136;
optional string plan_mode_cline_pass_model_id = 137;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -326,6 +329,8 @@ message ModelsApiOptions {
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 234;
optional string act_mode_cline_model_id = 235;
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
optional string act_mode_cline_pass_model_id = 237;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 238;
}
// Request for updating API configuration (legacy - uses combined configuration)
@@ -461,6 +466,7 @@ enum ApiProvider {
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
}
enum ApiFormat {
@@ -644,6 +650,8 @@ message ModelsApiConfiguration {
optional string gemini_plan_mode_thinking_level = 139;
optional string plan_mode_cline_model_id = 140;
optional OpenRouterModelInfo plan_mode_cline_model_info = 141;
optional string plan_mode_cline_pass_model_id = 142;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -688,4 +696,6 @@ message ModelsApiConfiguration {
optional string gemini_act_mode_thinking_level = 239;
optional string act_mode_cline_model_id = 240;
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
optional string act_mode_cline_pass_model_id = 242;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 243;
}
+34 -5
View File
@@ -1,6 +1,8 @@
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
import { ApiConfiguration, clinePassDefaultModelId, ModelInfo, QwenApiRegions, resolveClinePassModelInfo } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { ClineTool } from "@/shared/tools"
import { AIhubmixHandler } from "./providers/aihubmix"
@@ -78,7 +80,10 @@ function createHandlerForProvider(
options: Omit<ApiConfiguration, "apiProvider">,
mode: Mode,
): ApiHandler {
switch (apiProvider) {
const effectiveApiProvider =
apiProvider === "cline-pass" && !featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS) ? "cline" : apiProvider
switch (effectiveApiProvider) {
case "anthropic":
return new AnthropicHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -258,11 +263,12 @@ function createHandlerForProvider(
mode === "plan" ? options.planModeVsCodeLmModelSelector : options.actModeVsCodeLmModelSelector,
})
case "cline": {
const configuredClineModelId = mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId
const configuredClineModelInfo = mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo
const clineModelId =
(mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId) ||
(mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
configuredClineModelId || (mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
const clineModelInfo =
(mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo) ||
configuredClineModelInfo ||
(mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -278,6 +284,29 @@ function createHandlerForProvider(
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "cline-pass": {
const configuredClinePassModelId =
mode === "plan" ? options.planModeClinePassModelId : options.actModeClinePassModelId
const configuredClinePassModelInfo =
mode === "plan" ? options.planModeClinePassModelInfo : options.actModeClinePassModelInfo
const clineModelId = configuredClinePassModelId?.startsWith("cline-pass/")
? configuredClinePassModelId
: clinePassDefaultModelId
const clineModelInfo = configuredClinePassModelInfo || resolveClinePassModelInfo(clineModelId)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
clineAccountId: options.clineAccountId,
clineApiKey: options.clineApiKey,
ulid: options.ulid,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: clineModelId,
openRouterModelInfo: clineModelInfo,
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "litellm":
return new LiteLlmHandler({
onRetryAttempt: options.onRetryAttempt,
+8 -3
View File
@@ -1,4 +1,4 @@
import { type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { clinePassModels, type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import axios from "axios"
import OpenAI from "openai"
@@ -37,7 +37,10 @@ function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
const CLINE_FREE_MODEL_IDS = new Set(CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)))
const CLINE_FREE_MODEL_IDS = new Set([
...CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)),
...Object.keys(clinePassModels).map((modelId) => normalizeModelId(modelId)),
])
function getCacheReadTokens(usage: any): number {
return usage?.prompt_tokens_details?.cached_tokens || usage?.cache_read_input_tokens || 0
@@ -67,7 +70,9 @@ export class ClineHandler implements ApiHandler {
private async getFreeModelIdSet(): Promise<Set<string>> {
try {
const models = await refreshClineRecommendedModels()
const freeModelIds = models.free.map((model) => normalizeModelId(model.id)).filter((modelId) => modelId.length > 0)
const freeModelIds = [...models.free, ...models.clinePass]
.map((model) => normalizeModelId(model.id))
.filter((modelId) => modelId.length > 0)
if (freeModelIds.length > 0) {
return new Set(freeModelIds)
}
@@ -1,27 +1,30 @@
import * as disk from "@core/storage/disk"
import axios from "axios"
import { expect } from "chai"
import fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineEnv, Environment } from "@/config"
import { Logger } from "@/shared/services/Logger"
import { refreshClineRecommendedModels, resetClineRecommendedModelsCacheForTests } from "../refreshClineRecommendedModels"
import * as disk from "@core/storage/disk";
import axios from "axios";
import { expect } from "chai";
import fs from "fs/promises";
import { afterEach, beforeEach, describe, it } from "mocha";
import sinon from "sinon";
import { ClineEnv, Environment } from "@/config";
import { Logger } from "@/shared/services/Logger";
import {
refreshClineRecommendedModels,
resetClineRecommendedModelsCacheForTests,
} from "../refreshClineRecommendedModels";
describe("refreshClineRecommendedModels", () => {
let sandbox: sinon.SinonSandbox
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox()
resetClineRecommendedModelsCacheForTests()
sandbox.stub(Logger, "log")
sandbox.stub(Logger, "error")
})
sandbox = sinon.createSandbox();
resetClineRecommendedModelsCacheForTests();
sandbox.stub(Logger, "log");
sandbox.stub(Logger, "error");
});
afterEach(() => {
resetClineRecommendedModelsCacheForTests()
sandbox.restore()
})
resetClineRecommendedModelsCacheForTests();
sandbox.restore();
});
it("fetches from upstream", async () => {
sandbox.stub(ClineEnv, "config").returns({
@@ -29,19 +32,32 @@ describe("refreshClineRecommendedModels", () => {
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(fs, "writeFile").resolves();
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "anthropic/claude-sonnet-4.6", description: "Remote recommended", tags: ["NEW"] }],
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
description: "Remote recommended",
tags: ["NEW"],
},
],
free: [{ id: "z-ai/glm-5", description: "Remote free" }],
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
tags: ["CLINE_PASS"],
},
],
},
})
});
const result = await refreshClineRecommendedModels()
const result = await refreshClineRecommendedModels();
expect(axiosGetStub.calledOnce).to.equal(true)
expect(axiosGetStub.calledOnce).to.equal(true);
expect(result).to.deep.equal({
recommended: [
{
@@ -59,8 +75,16 @@ describe("refreshClineRecommendedModels", () => {
tags: [],
},
],
})
})
clinePass: [
{
id: "cline-pass/glm-5",
name: "cline-pass/glm-5",
description: "Remote Cline Pass",
tags: ["CLINE_PASS"],
},
],
});
});
it("uses the in-memory cache after upstream cache is populated", async () => {
sandbox.stub(ClineEnv, "config").returns({
@@ -68,20 +92,39 @@ describe("refreshClineRecommendedModels", () => {
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(fs, "writeFile").resolves();
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "google/gemini-3.1-pro-preview", description: "Remote recommended", tags: ["NEW"] }],
free: [{ id: "minimax/minimax-m2.5", description: "Remote free", tags: ["FREE"] }],
recommended: [
{
id: "google/gemini-3.1-pro-preview",
description: "Remote recommended",
tags: ["NEW"],
},
],
free: [
{
id: "minimax/minimax-m2.5",
description: "Remote free",
tags: ["FREE"],
},
],
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
tags: ["CLINE_PASS"],
},
],
},
})
});
const firstResult = await refreshClineRecommendedModels()
const secondResult = await refreshClineRecommendedModels()
const firstResult = await refreshClineRecommendedModels();
const secondResult = await refreshClineRecommendedModels();
expect(axiosGetStub.calledOnce).to.equal(true)
expect(secondResult).to.deep.equal(firstResult)
})
})
expect(axiosGetStub.calledOnce).to.equal(true);
expect(secondResult).to.deep.equal(firstResult);
});
});
@@ -16,12 +16,16 @@ export interface ClineRecommendedModelData {
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModelData[]
free: ClineRecommendedModelData[]
clinePass: ClineRecommendedModelData[]
}
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null
let inMemoryCache: { data: ClineRecommendedModelsData; timestamp: number } | null = null
let inMemoryCache: {
data: ClineRecommendedModelsData
timestamp: number
} | null = null
function normalizeRecommendedModel(raw: unknown): ClineRecommendedModelData | null {
if (!raw || typeof raw !== "object") {
@@ -49,13 +53,16 @@ function normalizeRecommendedModelsResponse(raw: unknown): ClineRecommendedModel
const data = raw as Record<string, unknown>
if (
(data.recommended !== undefined && !Array.isArray(data.recommended)) ||
(data.free !== undefined && !Array.isArray(data.free))
(data.free !== undefined && !Array.isArray(data.free)) ||
(data.clinePass !== undefined && !Array.isArray(data.clinePass)) ||
(data.cline_pass !== undefined && !Array.isArray(data.cline_pass))
) {
return null
}
const recommendedRaw = Array.isArray(data.recommended) ? data.recommended : []
const freeRaw = Array.isArray(data.free) ? data.free : []
const clinePassRaw = Array.isArray(data.clinePass) ? data.clinePass : Array.isArray(data.cline_pass) ? data.cline_pass : []
const recommended = recommendedRaw
.map((model) => normalizeRecommendedModel(model))
@@ -65,7 +72,11 @@ function normalizeRecommendedModelsResponse(raw: unknown): ClineRecommendedModel
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
return { recommended, free }
const clinePass = clinePassRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
return { recommended, free, clinePass }
}
export async function refreshClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
@@ -95,7 +106,11 @@ export function resetClineRecommendedModelsCacheForTests(): void {
async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
const clineRecommendedModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineRecommendedModels)
let result: ClineRecommendedModelsData = { recommended: [], free: [] }
let result: ClineRecommendedModelsData = {
recommended: [],
free: [],
clinePass: [],
}
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
@@ -120,7 +135,11 @@ async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedMo
const fileContents = await fs.readFile(clineRecommendedModelsFilePath, "utf8")
const parsed = JSON.parse(fileContents)
if (parsed) {
result = parsed
result = {
recommended: Array.isArray(parsed.recommended) ? parsed.recommended : [],
free: Array.isArray(parsed.free) ? parsed.free : [],
clinePass: Array.isArray(parsed.clinePass) ? parsed.clinePass : [],
}
Logger.log("Loaded Cline recommended models from cache")
}
}
@@ -130,7 +149,7 @@ async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedMo
}
// Avoid pinning empty results in memory for the full TTL after a transient API/cache miss.
if (result.recommended.length > 0 || result.free.length > 0) {
if (result.recommended.length > 0 || result.free.length > 0 || result.clinePass.length > 0) {
inMemoryCache = { data: result, timestamp: Date.now() }
}
return result
@@ -25,5 +25,13 @@ export async function refreshClineRecommendedModelsRpc(
tags: model.tags,
}),
),
clinePass: models.clinePass.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
})
}
@@ -1,16 +1,16 @@
import { Empty } from "@shared/proto/cline/common"
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Empty } from "@shared/proto/cline/common";
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models";
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion";
import {
fromProtobufLiteLLMModelInfo,
fromProtobufModelInfo,
fromProtobufOcaModelInfo,
fromProtobufOpenAiCompatibleModelInfo,
} from "@shared/proto-conversions/models/typeConversion"
import { OpenaiReasoningEffort } from "@shared/storage/types"
import { buildApiHandler } from "@/core/api"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
} from "@shared/proto-conversions/models/typeConversion";
import { OpenaiReasoningEffort } from "@shared/storage/types";
import { buildApiHandler } from "@/core/api";
import { Logger } from "@/shared/services/Logger";
import type { Controller } from "../index";
/**
* Updates API configuration
@@ -24,18 +24,22 @@ export async function updateApiConfigurationProto(
): Promise<Empty> {
try {
if (!request.apiConfiguration) {
Logger.log("[APICONFIG: updateApiConfigurationProto] API configuration is required")
throw new Error("API configuration is required")
Logger.log(
"[APICONFIG: updateApiConfigurationProto] API configuration is required",
);
throw new Error("API configuration is required");
}
const protoApiConfiguration = request.apiConfiguration
const protoApiConfiguration = request.apiConfiguration;
const convertedApiConfigurationFromProto = {
...protoApiConfiguration,
// Convert proto ApiProvider enums to native string types
planModeApiProvider:
protoApiConfiguration.planModeApiProvider !== undefined
? convertProtoToApiProvider(protoApiConfiguration.planModeApiProvider!)
? convertProtoToApiProvider(
protoApiConfiguration.planModeApiProvider!,
)
: undefined,
actModeApiProvider:
protoApiConfiguration.actModeApiProvider !== undefined
@@ -44,20 +48,36 @@ export async function updateApiConfigurationProto(
// Convert ModelInfo objects (empty arrays → undefined)
// Plan Mode
planModeOpenRouterModelInfo: protoApiConfiguration.planModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeOpenRouterModelInfo)
: undefined,
planModeOpenRouterModelInfo:
protoApiConfiguration.planModeOpenRouterModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeOpenRouterModelInfo,
)
: undefined,
planModeClineModelInfo: protoApiConfiguration.planModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeClineModelInfo)
: undefined,
planModeClinePassModelInfo:
protoApiConfiguration.planModeClinePassModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeClinePassModelInfo,
)
: undefined,
planModeOpenAiModelInfo: protoApiConfiguration.planModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeOpenAiModelInfo)
: undefined,
planModeHuggingFaceModelInfo: protoApiConfiguration.planModeHuggingFaceModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeHuggingFaceModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.planModeOpenAiModelInfo,
)
: undefined,
planModeHuggingFaceModelInfo:
protoApiConfiguration.planModeHuggingFaceModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeHuggingFaceModelInfo,
)
: undefined,
planModeLiteLlmModelInfo: protoApiConfiguration.planModeLiteLlmModelInfo
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.planModeLiteLlmModelInfo)
? fromProtobufLiteLLMModelInfo(
protoApiConfiguration.planModeLiteLlmModelInfo,
)
: undefined,
planModeRequestyModelInfo: protoApiConfiguration.planModeRequestyModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeRequestyModelInfo)
@@ -65,34 +85,52 @@ export async function updateApiConfigurationProto(
planModeGroqModelInfo: protoApiConfiguration.planModeGroqModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeGroqModelInfo)
: undefined,
planModeHuaweiCloudMaasModelInfo: protoApiConfiguration.planModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeHuaweiCloudMaasModelInfo)
: undefined,
planModeHuaweiCloudMaasModelInfo:
protoApiConfiguration.planModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeHuaweiCloudMaasModelInfo,
)
: undefined,
planModeBasetenModelInfo: protoApiConfiguration.planModeBasetenModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeBasetenModelInfo)
: undefined,
planModeVercelAiGatewayModelInfo: protoApiConfiguration.planModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeVercelAiGatewayModelInfo)
: undefined,
planModeVercelAiGatewayModelInfo:
protoApiConfiguration.planModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeVercelAiGatewayModelInfo,
)
: undefined,
planModeOcaModelInfo: protoApiConfiguration.planModeOcaModelInfo
? fromProtobufOcaModelInfo(protoApiConfiguration.planModeOcaModelInfo)
: undefined,
planModeAihubmixModelInfo: protoApiConfiguration.planModeAihubmixModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeAihubmixModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.planModeAihubmixModelInfo,
)
: undefined,
// Act Mode
actModeOpenRouterModelInfo: protoApiConfiguration.actModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeOpenRouterModelInfo)
: undefined,
actModeOpenRouterModelInfo:
protoApiConfiguration.actModeOpenRouterModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeOpenRouterModelInfo,
)
: undefined,
actModeClineModelInfo: protoApiConfiguration.actModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClineModelInfo)
: undefined,
actModeClinePassModelInfo: protoApiConfiguration.actModeClinePassModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClinePassModelInfo)
: undefined,
actModeOpenAiModelInfo: protoApiConfiguration.actModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeOpenAiModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.actModeOpenAiModelInfo,
)
: undefined,
actModeLiteLlmModelInfo: protoApiConfiguration.actModeLiteLlmModelInfo
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.actModeLiteLlmModelInfo)
? fromProtobufLiteLLMModelInfo(
protoApiConfiguration.actModeLiteLlmModelInfo,
)
: undefined,
actModeRequestyModelInfo: protoApiConfiguration.actModeRequestyModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeRequestyModelInfo)
@@ -100,48 +138,67 @@ export async function updateApiConfigurationProto(
actModeGroqModelInfo: protoApiConfiguration.actModeGroqModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeGroqModelInfo)
: undefined,
actModeHuggingFaceModelInfo: protoApiConfiguration.actModeHuggingFaceModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeHuggingFaceModelInfo)
: undefined,
actModeHuaweiCloudMaasModelInfo: protoApiConfiguration.actModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeHuaweiCloudMaasModelInfo)
: undefined,
actModeHuggingFaceModelInfo:
protoApiConfiguration.actModeHuggingFaceModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeHuggingFaceModelInfo,
)
: undefined,
actModeHuaweiCloudMaasModelInfo:
protoApiConfiguration.actModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeHuaweiCloudMaasModelInfo,
)
: undefined,
actModeBasetenModelInfo: protoApiConfiguration.actModeBasetenModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeBasetenModelInfo)
: undefined,
actModeVercelAiGatewayModelInfo: protoApiConfiguration.actModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeVercelAiGatewayModelInfo)
: undefined,
actModeVercelAiGatewayModelInfo:
protoApiConfiguration.actModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeVercelAiGatewayModelInfo,
)
: undefined,
actModeOcaModelInfo: protoApiConfiguration.actModeOcaModelInfo
? fromProtobufOcaModelInfo(protoApiConfiguration.actModeOcaModelInfo)
: undefined,
actModeAihubmixModelInfo: protoApiConfiguration.actModeAihubmixModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeAihubmixModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.actModeAihubmixModelInfo,
)
: undefined,
geminiPlanModeThinkingLevel: protoApiConfiguration.geminiPlanModeThinkingLevel,
geminiActModeThinkingLevel: protoApiConfiguration.geminiActModeThinkingLevel,
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as OpenaiReasoningEffort | undefined,
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
}
geminiPlanModeThinkingLevel:
protoApiConfiguration.geminiPlanModeThinkingLevel,
geminiActModeThinkingLevel:
protoApiConfiguration.geminiActModeThinkingLevel,
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as
| OpenaiReasoningEffort
| undefined,
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as
| OpenaiReasoningEffort
| undefined,
};
// Update the API configuration in storage
controller.stateManager.setApiConfiguration(convertedApiConfigurationFromProto)
controller.stateManager.setApiConfiguration(
convertedApiConfigurationFromProto,
);
// Update the task's API handler if there's an active task
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const currentMode = controller.stateManager.getGlobalSettingsKey("mode");
controller.task.api = buildApiHandler(
{ ...convertedApiConfigurationFromProto, ulid: controller.task.ulid },
currentMode,
)
);
}
// Post updated state to webview
await controller.postStateToWebview()
await controller.postStateToWebview();
return Empty.create()
return Empty.create();
} catch (error) {
Logger.error(`Failed to update API configuration: ${error}`)
throw error
Logger.error(`Failed to update API configuration: ${error}`);
throw error;
}
}
+7 -1
View File
@@ -87,6 +87,7 @@ import {
} from "@shared/Languages";
import { USER_CONTENT_TAGS } from "@shared/messages/constants";
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message";
import { FeatureFlag } from "@shared/services/feature-flags/feature-flags";
import { type ClineDefaultTool, READ_ONLY_TOOLS } from "@shared/tools";
import type { ClineAskResponse } from "@shared/WebviewMessage";
import {
@@ -2033,11 +2034,16 @@ export class Task {
const model = this.api.getModel();
const apiConfig = this.stateManager.getApiConfiguration();
const mode = this.stateManager.getGlobalSettingsKey("mode");
const providerId = (
const configuredProviderId = (
mode === "plan"
? apiConfig.planModeApiProvider
: apiConfig.actModeApiProvider
) as string;
const providerId =
configuredProviderId === "cline-pass" &&
!featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS)
? "cline"
: configuredProviderId;
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt");
return { model, providerId, customPrompt, mode };
}
+53
View File
@@ -22,6 +22,7 @@ export type ApiProvider =
| "mistral"
| "vscode-lm"
| "cline"
| "cline-pass"
| "litellm"
| "moonshot"
| "nebius"
@@ -1023,6 +1024,58 @@ export const clineDevstralModelInfo: ModelInfo = {
description: "A stealth model for agentic coding tasks",
}
export type ClinePassModelId = keyof typeof clinePassModels
export const clinePassDefaultModelId = "cline-pass/glm-5.1"
export const clinePassModelInfoSaneDefaults: ModelInfo = {
maxTokens: 8_192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
supportsReasoning: true,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
description: "",
}
export const clinePassModels = {
"cline-pass/glm-5.1": {
name: "cline-pass/glm-5.1",
maxTokens: 131_072,
contextWindow: 202_752,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 0.98,
outputPrice: 3.08,
cacheReadsPrice: 0.182,
cacheWritesPrice: 0,
description: "",
},
} as const satisfies Record<string, ModelInfo>
export function getModelSlug(modelId: string): string {
return modelId.split("/").at(-1) ?? modelId
}
export function buildModelInfoNameMap(models: Record<string, ModelInfo>): Record<string, ModelInfo> {
const nameMap: Record<string, ModelInfo> = {}
for (const [id, info] of Object.entries(models)) {
nameMap[getModelSlug(id)] = info
}
return nameMap
}
export function resolveClinePassModelInfo(modelId: string, modelInfoByName?: Record<string, ModelInfo>): ModelInfo {
return (
clinePassModels[modelId as keyof typeof clinePassModels] ??
modelInfoByName?.[getModelSlug(modelId)] ??
clinePassModelInfoSaneDefaults
)
}
export const OPENROUTER_PROVIDER_PREFERENCES: Record<string, { order: string[]; allow_fallbacks: boolean }> = {
// Exacto Providers
"moonshotai/kimi-k2:exacto": {
@@ -280,6 +280,8 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
return ProtoApiProvider.VSCODE_LM
case "cline":
return ProtoApiProvider.CLINE
case "cline-pass":
return ProtoApiProvider.CLINE_PASS
case "litellm":
return ProtoApiProvider.LITELLM
case "moonshot":
@@ -372,6 +374,8 @@ export function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvid
return "vscode-lm"
case ProtoApiProvider.CLINE:
return "cline"
case ProtoApiProvider.CLINE_PASS:
return "cline-pass"
case ProtoApiProvider.LITELLM:
return "litellm"
case ProtoApiProvider.MOONSHOT:
@@ -528,6 +532,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
planModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.planModeOpenRouterModelInfo),
planModeClineModelId: config.planModeClineModelId,
planModeClineModelInfo: convertModelInfoToProtoOpenRouter(config.planModeClineModelInfo),
planModeClinePassModelId: config.planModeClinePassModelId,
planModeClinePassModelInfo: convertModelInfoToProtoOpenRouter(config.planModeClinePassModelInfo),
planModeOpenAiModelId: config.planModeOpenAiModelId,
planModeOpenAiModelInfo: convertOpenAiCompatibleModelInfoToProto(config.planModeOpenAiModelInfo),
planModeOllamaModelId: config.planModeOllamaModelId,
@@ -572,6 +578,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
actModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.actModeOpenRouterModelInfo),
actModeClineModelId: config.actModeClineModelId,
actModeClineModelInfo: convertModelInfoToProtoOpenRouter(config.actModeClineModelInfo),
actModeClinePassModelId: config.actModeClinePassModelId,
actModeClinePassModelInfo: convertModelInfoToProtoOpenRouter(config.actModeClinePassModelInfo),
actModeOpenAiModelId: config.actModeOpenAiModelId,
actModeOpenAiModelInfo: convertOpenAiCompatibleModelInfoToProto(config.actModeOpenAiModelInfo),
actModeOllamaModelId: config.actModeOllamaModelId,
@@ -711,6 +719,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
planModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.planModeOpenRouterModelInfo),
planModeClineModelId: protoConfig.planModeClineModelId,
planModeClineModelInfo: convertProtoToModelInfo(protoConfig.planModeClineModelInfo),
planModeClinePassModelId: protoConfig.planModeClinePassModelId,
planModeClinePassModelInfo: convertProtoToModelInfo(protoConfig.planModeClinePassModelInfo),
planModeOpenAiModelId: protoConfig.planModeOpenAiModelId,
planModeOpenAiModelInfo: convertProtoToOpenAiCompatibleModelInfo(protoConfig.planModeOpenAiModelInfo),
planModeOllamaModelId: protoConfig.planModeOllamaModelId,
@@ -756,6 +766,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
actModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.actModeOpenRouterModelInfo),
actModeClineModelId: protoConfig.actModeClineModelId,
actModeClineModelInfo: convertProtoToModelInfo(protoConfig.actModeClineModelInfo),
actModeClinePassModelId: protoConfig.actModeClinePassModelId,
actModeClinePassModelInfo: convertProtoToModelInfo(protoConfig.actModeClinePassModelInfo),
actModeOpenAiModelId: protoConfig.actModeOpenAiModelId,
actModeOpenAiModelInfo: convertProtoToOpenAiCompatibleModelInfo(protoConfig.actModeOpenAiModelInfo),
actModeOllamaModelId: protoConfig.actModeOllamaModelId,
@@ -4,6 +4,10 @@
"value": "cline",
"label": "Cline"
},
{
"value": "cline-pass",
"label": "Cline Pass"
},
{
"value": "openai-codex",
"label": "ChatGPT Subscription"
@@ -15,6 +15,8 @@ export enum FeatureFlag {
// Rollout flag for Cline provider model sourcing:
// off => OpenRouter model list, on => Cline endpoint model list.
EXTENSION_CLINE_MODELS_ENDPOINT = "extension_cline_models_endpoint",
// Enables Cline Pass provider/model list exposure.
CLINE_PASS = "ext-cline-pass",
// Use the websocket mode for OpenAI native Responses API format
OPENAI_RESPONSES_WEBSOCKET_MODE = "openai-responses-websocket-mode",
}
@@ -27,6 +29,7 @@ export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPay
[FeatureFlag.EXTENSION_REMOTE_BANNERS_TTL]: 24 * 60 * 60 * 1000,
[FeatureFlag.REMOTE_WELCOME_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
[FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT]: false,
[FeatureFlag.CLINE_PASS]: false,
[FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE]: false,
}
@@ -22,4 +22,9 @@ describe("Provider key mapping", () => {
expect(getProviderModelIdKey("cline", "act")).to.equal("actModeClineModelId")
expect(getProviderModelIdKey("cline", "plan")).to.equal("planModeClineModelId")
})
it("uses separate model keys for Cline Pass", () => {
expect(getProviderModelIdKey("cline-pass", "act")).to.equal("actModeClinePassModelId")
expect(getProviderModelIdKey("cline-pass", "plan")).to.equal("planModeClinePassModelId")
})
})
@@ -6,6 +6,7 @@ import {
anthropicDefaultModelId,
basetenDefaultModelId,
bedrockDefaultModelId,
clinePassDefaultModelId,
deepSeekDefaultModelId,
fireworksDefaultModelId,
geminiDefaultModelId,
@@ -28,6 +29,7 @@ import {
const ProviderKeyMap: Partial<Record<ApiProvider, string>> = {
openrouter: "OpenRouterModelId",
cline: "ClineModelId",
"cline-pass": "ClinePassModelId",
openai: "OpenAiModelId",
ollama: "OllamaModelId",
lmstudio: "LmStudioModelId",
@@ -49,6 +51,7 @@ const ProviderKeyMap: Partial<Record<ApiProvider, string>> = {
export const ProviderToApiKeyMap: Partial<Record<ApiProvider, keyof Secrets | (keyof Secrets)[]>> = {
cline: ["clineApiKey", "clineAccountId"],
"cline-pass": ["clineApiKey", "clineAccountId"],
anthropic: "apiKey",
openrouter: "openRouterApiKey",
bedrock: ["awsAccessKey", "awsBedrockApiKey"],
@@ -91,6 +94,7 @@ const ProviderDefaultModelMap: Partial<Record<ApiProvider, string>> = {
anthropic: anthropicDefaultModelId,
openrouter: openRouterDefaultModelId,
cline: openRouterDefaultModelId,
"cline-pass": clinePassDefaultModelId,
openai: openAiNativeDefaultModelId,
ollama: "",
lmstudio: "",
@@ -156,6 +156,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
planModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
planModeClineModelId: { default: undefined as string | undefined },
planModeClineModelInfo: { default: undefined as ModelInfo | undefined },
planModeClinePassModelId: { default: undefined as string | undefined },
planModeClinePassModelInfo: { default: undefined as ModelInfo | undefined },
planModeOpenAiModelId: { default: undefined as string | undefined },
planModeOpenAiModelInfo: { default: undefined as OpenAiCompatibleModelInfo | undefined },
planModeOllamaModelId: { default: undefined as string | undefined },
@@ -200,6 +202,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
actModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
actModeClineModelId: { default: undefined as string | undefined },
actModeClineModelInfo: { default: undefined as ModelInfo | undefined },
actModeClinePassModelId: { default: undefined as string | undefined },
actModeClinePassModelInfo: { default: undefined as ModelInfo | undefined },
actModeOpenAiModelId: { default: undefined as string | undefined },
actModeOpenAiModelInfo: { default: undefined as OpenAiCompatibleModelInfo | undefined },
actModeOllamaModelId: { default: undefined as string | undefined },
+1
View File
@@ -10,6 +10,7 @@ export function isNextGenModelProvider(providerInfo: ApiProviderInfo): boolean {
const providerId = normalize(providerInfo.providerId)
return [
"cline",
"cline-pass",
"anthropic",
"bedrock",
"gemini",
@@ -0,0 +1,33 @@
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
import { useClineAuth } from "@/context/ClineAuthContext"
// Fall back to production when the authenticated user's app base URL is unavailable.
const DEFAULT_APP_BASE_URL = "https://app.cline.bot"
// Cline Pass subscribe/manage page in the dashboard.
const CLINE_PASS_SUBSCRIBE_PATH = "/dashboard/plan"
/**
* Optional subscribe affordance shown after a new user picks Cline Pass and
* starts account creation. The account login itself is the existing OAuth flow;
* this only surfaces a link to the dashboard subscribe page so the user can
* activate Cline Pass. Purely additive — it does not change the onboarding flow.
*/
export const ClinePassSubscribeCallout = () => {
const { clineUser } = useClineAuth()
// Use the environment-aware app base URL (e.g. staging-app.cline.bot on staging)
// so the subscribe link points at the same environment the user signs into.
const appBaseUrl = clineUser?.appBaseUrl || DEFAULT_APP_BASE_URL
const subscribeUrl = `${appBaseUrl}${CLINE_PASS_SUBSCRIBE_PATH}`
return (
<div className="flex w-full max-w-lg flex-col gap-2 my-2 items-center">
<p className="text-foreground/70 text-sm text-center m-0">
Activate Cline Pass to unlock curated models no API keys to manage.
</p>
<VSCodeButtonLink className="w-full" href={subscribeUrl}>
<span className="codicon codicon-rocket mr-[6px] text-[14px]" />
Get Cline Pass
</VSCodeButtonLink>
</div>
)
}
@@ -1,4 +1,4 @@
import type { ModelInfo } from "@shared/api"
import { buildModelInfoNameMap, type ModelInfo, resolveClinePassModelInfo } from "@shared/api"
import type { OnboardingModel, OnboardingModelGroup, OpenRouterModelInfo } from "@shared/proto/index.cline"
import { AlertCircleIcon, CircleCheckIcon, CircleIcon, ListIcon, LoaderCircleIcon, ZapIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
@@ -7,12 +7,15 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Item, ItemContent, ItemDescription, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { cn } from "@/lib/utils"
import { AccountServiceClient, StateServiceClient } from "@/services/grpc-client"
import ApiConfigurationSection from "../settings/sections/ApiConfigurationSection"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
import WelcomeView from "../welcome/WelcomeView"
import { ClinePassSubscribeCallout } from "./ClinePassSubscribeCallout"
import {
getCapabilities,
getClineUIOnboardingGroups,
@@ -20,11 +23,11 @@ import {
getSpeedLabel,
type OnboardingModelsByGroup,
} from "./data-models"
import { NEW_USER_TYPE, STEP_CONFIG, USER_TYPE_SELECTIONS } from "./data-steps"
import { getUserTypeSelections, NEW_USER_TYPE, STEP_CONFIG } from "./data-steps"
import { useOnboardingModels } from "./useOnboardingModels"
type ModelSelectionProps = {
userType: NEW_USER_TYPE.FREE | NEW_USER_TYPE.POWER
userType: NEW_USER_TYPE.FREE | NEW_USER_TYPE.POWER | NEW_USER_TYPE.CLINE_PASS
selectedModelId: string
onSelectModel: (modelId: string) => void
onboardingModels: OnboardingModelsByGroup
@@ -33,6 +36,13 @@ type ModelSelectionProps = {
setSearchTerm: (term: string) => void
}
function getModelGroupKey(userType: ModelSelectionProps["userType"]): keyof OnboardingModelsByGroup {
if (userType === NEW_USER_TYPE.CLINE_PASS) {
return "clinePass"
}
return userType === NEW_USER_TYPE.FREE ? "free" : "power"
}
const ModelSelection = ({
userType,
selectedModelId,
@@ -42,7 +52,9 @@ const ModelSelection = ({
setSearchTerm,
onboardingModels,
}: ModelSelectionProps) => {
const modelGroups = onboardingModels[userType === NEW_USER_TYPE.FREE ? "free" : "power"]
const modelGroups = onboardingModels[getModelGroupKey(userType)]
// Cline Pass costs are covered by the subscription, so price badges/ranges are not shown.
const hidePrice = userType === NEW_USER_TYPE.CLINE_PASS
const searchedModels = useMemo(() => {
if (!models || !searchTerm) {
@@ -73,7 +85,7 @@ const ModelSelection = ({
<Badge className="capitalize" variant="info">
{model.badge}
</Badge>
) : model.info ? (
) : !hidePrice && model.info ? (
<Badge>{getPriceRange(model.info)}</Badge>
) : null}
</ItemTitle>
@@ -99,7 +111,7 @@ const ModelSelection = ({
<span>Context: </span>
<span className="text-foreground/70">{(model?.info.contextWindow || 0) / 1000}k</span>
</div>
<Badge>{getPriceRange(model.info)}</Badge>
{!hidePrice && <Badge>{getPriceRange(model.info)}</Badge>}
</div>
)}
</div>
@@ -190,12 +202,13 @@ const ModelSelection = ({
type UserTypeSelectionProps = {
userType: NEW_USER_TYPE | undefined
onSelectUserType: (type: NEW_USER_TYPE) => void
userTypeSelections: ReturnType<typeof getUserTypeSelections>
}
const UserTypeSelectionStep = ({ userType, onSelectUserType }: UserTypeSelectionProps) => (
const UserTypeSelectionStep = ({ userType, onSelectUserType, userTypeSelections }: UserTypeSelectionProps) => (
<div className="flex flex-col w-full items-center">
<div className="flex w-full max-w-lg flex-col gap-3 my-2">
{USER_TYPE_SELECTIONS.map((option) => {
{userTypeSelections.map((option) => {
const isSelected = userType === option.type
return (
@@ -229,6 +242,7 @@ type OnboardingStepContentProps = {
setSearchTerm: (term: string) => void
models?: Record<string, ModelInfo>
onboardingModels: OnboardingModelsByGroup
userTypeSelections: ReturnType<typeof getUserTypeSelections>
}
const OnboardingStepContent = ({
@@ -241,14 +255,21 @@ const OnboardingStepContent = ({
setSearchTerm,
models,
onboardingModels,
userTypeSelections,
}: OnboardingStepContentProps) => {
if (step === 0) {
return <UserTypeSelectionStep onSelectUserType={onSelectUserType} userType={userType} />
return (
<UserTypeSelectionStep
onSelectUserType={onSelectUserType}
userType={userType}
userTypeSelections={userTypeSelections}
/>
)
}
if (step === 2) {
return null
}
if (userType === NEW_USER_TYPE.FREE || userType === NEW_USER_TYPE.POWER) {
if (userType === NEW_USER_TYPE.FREE || userType === NEW_USER_TYPE.POWER || userType === NEW_USER_TYPE.CLINE_PASS) {
return (
<ModelSelection
models={models}
@@ -268,6 +289,8 @@ const OnboardingStepContent = ({
const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: OnboardingModelGroup }) => {
const { handleFieldsChange } = useApiConfigurationHandlers()
const { openRouterModels, hideSettings, hideAccount, setShowWelcome } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const userTypeSelections = useMemo(() => getUserTypeSelections(isClinePassEnabled), [isClinePassEnabled])
const [stepNumber, setStepNumber] = useState(0)
const [isActionLoading, setIsActionLoading] = useState(false)
@@ -277,23 +300,29 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
const [searchTerm, setSearchTerm] = useState("")
const models = useMemo(() => getClineUIOnboardingGroups(onboardingModels), [onboardingModels])
// Cline Pass model IDs (e.g. "cline-pass/glm-5.1") are not keyed in openRouterModels,
// so resolve their info via the slug-based lookup used by ClinePassProvider.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
useEffect(() => {
setSearchTerm("")
const userGroup = userType === NEW_USER_TYPE.POWER ? NEW_USER_TYPE.POWER : NEW_USER_TYPE.FREE
const modelGroup = models[userGroup][0]
const userGroupInitModel = modelGroup.models[0]
setSelectedModelId(userGroupInitModel.id)
const groupKey = userType === NEW_USER_TYPE.CLINE_PASS ? "clinePass" : userType === NEW_USER_TYPE.POWER ? "power" : "free"
// Some groups can be empty (e.g. Cline Pass list not returned yet); fall back to free.
const modelGroup = models[groupKey][0] ?? models.free[0]
const userGroupInitModel = modelGroup?.models[0]
setSelectedModelId(userGroupInitModel?.id ?? "")
}, [userType, models])
const onUserTypeClick = useCallback((userType: NEW_USER_TYPE) => {
setUserType(userType)
const action =
userType === NEW_USER_TYPE.POWER
? "power_user_selected"
: userType === NEW_USER_TYPE.FREE
? "free_user_selected"
: "byok_user_selected"
userType === NEW_USER_TYPE.CLINE_PASS
? "cline_pass_user_selected"
: userType === NEW_USER_TYPE.POWER
? "power_user_selected"
: userType === NEW_USER_TYPE.FREE
? "free_user_selected"
: "byok_user_selected"
// User selection is available in step 0 only
StateServiceClient.captureOnboardingProgress({ step: 0, action })
}, [])
@@ -308,21 +337,35 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
async (updateModelId: boolean, step: number) => {
const modelSelected = (updateModelId && selectedModelId) || undefined
if (modelSelected) {
await handleFieldsChange({
planModeOpenRouterModelId: selectedModelId,
actModeOpenRouterModelId: selectedModelId,
planModeOpenRouterModelInfo: openRouterModels[selectedModelId],
actModeOpenRouterModelInfo: openRouterModels[selectedModelId],
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
if (userType === NEW_USER_TYPE.CLINE_PASS) {
// Cline Pass uses its own provider + model fields; costs are covered by the
// subscription and it routes through the Cline endpoint.
const clinePassModelInfo = resolveClinePassModelInfo(selectedModelId, openRouterModelsByName)
await handleFieldsChange({
planModeClinePassModelId: selectedModelId,
actModeClinePassModelId: selectedModelId,
planModeClinePassModelInfo: clinePassModelInfo,
actModeClinePassModelInfo: clinePassModelInfo,
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
})
} else {
await handleFieldsChange({
planModeOpenRouterModelId: selectedModelId,
actModeOpenRouterModelId: selectedModelId,
planModeOpenRouterModelInfo: openRouterModels[selectedModelId],
actModeOpenRouterModelInfo: openRouterModels[selectedModelId],
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
}
}
hideAccount()
hideSettings()
const action = "onboarding_completed"
StateServiceClient.captureOnboardingProgress({ step, modelSelected, action, completed: true })
},
[hideAccount, hideSettings, handleFieldsChange, selectedModelId, openRouterModels],
[hideAccount, hideSettings, handleFieldsChange, selectedModelId, openRouterModels, openRouterModelsByName, userType],
)
const handleFooterAction = useCallback(
@@ -377,6 +420,7 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
{stepNumber === 2 && (
<div className="flex w-full max-w-lg flex-col gap-6 my-4 items-center ">
<LoaderCircleIcon className="animate-spin" />
{userType === NEW_USER_TYPE.CLINE_PASS && <ClinePassSubscribeCallout />}
</div>
)}
{stepDisplayInfo.description && (
@@ -394,6 +438,7 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
setSearchTerm={setSearchTerm}
step={stepNumber}
userType={userType}
userTypeSelections={userTypeSelections}
/>
</div>
@@ -0,0 +1,43 @@
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { describe, expect, it } from "vitest"
import { getClineUIOnboardingGroups } from "../data-models"
function model(id: string, group: string): OnboardingModel {
return {
id,
name: id,
group,
badge: "",
score: 0,
latency: 0,
info: undefined,
} as OnboardingModel
}
function groupOf(models: OnboardingModel[]): OnboardingModelGroup {
return { models } as OnboardingModelGroup
}
describe("getClineUIOnboardingGroups", () => {
it("buckets Cline Pass models into the clinePass group", () => {
const result = getClineUIOnboardingGroups(
groupOf([
model("cline-pass/glm-5.1", "cline pass"),
model("free-model", "free"),
model("anthropic/claude", "frontier"),
model("z-ai/glm", "open source"),
]),
)
expect(result.clinePass).toHaveLength(1)
expect(result.clinePass[0].group).toBe("cline pass")
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.1"])
expect(result.free[0].models.map((m) => m.id)).toEqual(["free-model"])
expect(result.power.flatMap((g) => g.models.map((m) => m.id))).toEqual(["anthropic/claude", "z-ai/glm"])
})
it("returns an empty clinePass group when no Cline Pass models are present", () => {
const result = getClineUIOnboardingGroups(groupOf([model("free-model", "free")]))
expect(result.clinePass).toEqual([])
})
})
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest"
import { getUserTypeSelections, NEW_USER_TYPE } from "../data-steps"
describe("getUserTypeSelections", () => {
it("omits the Cline Pass option when the flag is disabled", () => {
const selections = getUserTypeSelections(false)
expect(selections.map((s) => s.type)).toEqual([NEW_USER_TYPE.FREE, NEW_USER_TYPE.POWER, NEW_USER_TYPE.BYOK])
expect(selections.some((s) => s.type === NEW_USER_TYPE.CLINE_PASS)).toBe(false)
})
it("surfaces Cline Pass first when the flag is enabled", () => {
const selections = getUserTypeSelections(true)
expect(selections[0]?.type).toBe(NEW_USER_TYPE.CLINE_PASS)
expect(selections.map((s) => s.type)).toEqual([
NEW_USER_TYPE.CLINE_PASS,
NEW_USER_TYPE.FREE,
NEW_USER_TYPE.POWER,
NEW_USER_TYPE.BYOK,
])
})
})
@@ -2,6 +2,7 @@ import type { OpenRouterModelInfo } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
export interface OnboardingModelsByGroup {
clinePass: ModelGroup[]
free: ModelGroup[]
power: ModelGroup[]
}
@@ -14,11 +15,13 @@ interface ModelGroup {
export function getClineUIOnboardingGroups(groupedModels: OnboardingModelGroup): OnboardingModelsByGroup {
const { models } = groupedModels
const clinePassModels = models.filter((m) => m.group === "cline pass")
const freeModels = models.filter((m) => m.group === "free")
const frontierModels = models.filter((m) => m.group === "frontier")
const openSourceModels = models.filter((m) => m.group === "open source")
return {
clinePass: clinePassModels.length > 0 ? [{ group: "cline pass", models: clinePassModels }] : [],
free: freeModels.length > 0 ? [{ group: "free", models: freeModels }] : [],
power: [
...(frontierModels.length > 0 ? [{ group: "frontier", models: frontierModels }] : []),
@@ -1,4 +1,5 @@
export enum NEW_USER_TYPE {
CLINE_PASS = "cline-pass",
FREE = "free",
POWER = "power",
BYOK = "byok",
@@ -19,6 +20,13 @@ export const STEP_CONFIG = {
{ text: "Login to Cline", action: "signin", variant: "secondary" },
],
},
[NEW_USER_TYPE.CLINE_PASS]: {
title: "Select a Cline Pass model",
buttons: [
{ text: "Create my Account", action: "signup", variant: "default" },
{ text: "Back", action: "back", variant: "secondary" },
],
},
[NEW_USER_TYPE.FREE]: {
title: "Select a free model",
buttons: [
@@ -47,8 +55,24 @@ export const STEP_CONFIG = {
},
} as const
export const USER_TYPE_SELECTIONS: UserTypeSelection[] = [
const CLINE_PASS_USER_TYPE_SELECTION: UserTypeSelection = {
title: "Cline Pass",
description: "Recommended — curated models, one subscription, no API keys to manage",
type: NEW_USER_TYPE.CLINE_PASS,
}
const BASE_USER_TYPE_SELECTIONS: UserTypeSelection[] = [
{ title: "Absolutely Free", description: "Get started at no cost", type: NEW_USER_TYPE.FREE },
{ title: "Frontier Model", description: "Claude, GPT Codex, Gemini, etc.", type: NEW_USER_TYPE.POWER },
{ title: "Bring my own API key", description: "Use Cline with your provider of choice", type: NEW_USER_TYPE.BYOK },
]
/**
* Returns the onboarding user-type options. Cline Pass is surfaced as a
* recommended-but-optional choice at the top of the list only when the
* `ext-cline-pass` feature flag is enabled; otherwise it is omitted entirely
* and the classic Free / Frontier / BYOK options are shown.
*/
export function getUserTypeSelections(isClinePassEnabled: boolean): UserTypeSelection[] {
return isClinePassEnabled ? [CLINE_PASS_USER_TYPE_SELECTION, ...BASE_USER_TYPE_SELECTIONS] : BASE_USER_TYPE_SELECTIONS
}
@@ -1,4 +1,4 @@
import type { ModelInfo } from "@shared/api"
import { buildModelInfoNameMap, type ModelInfo, resolveClinePassModelInfo } from "@shared/api"
import { CLINE_ONBOARDING_MODELS } from "@shared/cline/onboarding"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { ClineRecommendedModel } from "@shared/proto/cline/models"
@@ -47,6 +47,7 @@ function toOnboardingModel(
interface RecommendedModelsData {
recommended: ClineRecommendedModel[]
free: ClineRecommendedModel[]
clinePass: ClineRecommendedModel[]
}
type FetchState = { status: "loading" } | { status: "success"; data: RecommendedModelsData } | { status: "empty" }
@@ -64,10 +65,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
if (!cancelled) {
const recommended = response.recommended ?? []
const free = response.free ?? []
if (recommended.length === 0 && free.length === 0) {
const clinePass = response.clinePass ?? []
if (recommended.length === 0 && free.length === 0 && clinePass.length === 0) {
setFetchState({ status: "empty" })
} else {
setFetchState({ status: "success", data: { recommended, free } })
setFetchState({ status: "success", data: { recommended, free, clinePass } })
}
}
} catch {
@@ -93,6 +95,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return { ...openRouterModels, ...(clineModels ?? {}) }
}, [openRouterModels, clineModels])
// Cline Pass model IDs omit the upstream lab (e.g. "cline-pass/glm-5.1"), so look up
// capabilities via the model slug against the OpenRouter catalog, falling back to
// conservative Cline Pass defaults. Mirrors ClinePassProvider's resolution.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
return useMemo<UseOnboardingModelsResult>(() => {
if (fetchState.status !== "success") {
return { status: fetchState.status, models: { models: CLINE_ONBOARDING_MODELS } }
@@ -101,7 +108,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
const { data } = fetchState
const freeModels = data.free.map((rec) => toOnboardingModel(rec, "free", "Free", modelCatalog))
const frontierModels = data.recommended.map((rec) => toOnboardingModel(rec, "frontier", "", modelCatalog))
const clinePassCatalog = Object.fromEntries(
data.clinePass.map((rec) => [rec.id, resolveClinePassModelInfo(rec.id, openRouterModelsByName)]),
)
const clinePassModels = data.clinePass.map((rec) => toOnboardingModel(rec, "cline pass", "", clinePassCatalog))
return { status: "success", models: { models: [...freeModels, ...frontierModels] } }
}, [fetchState, modelCatalog])
return { status: "success", models: { models: [...clinePassModels, ...freeModels, ...frontierModels] } }
}, [fetchState, modelCatalog, openRouterModelsByName])
}
@@ -9,7 +9,9 @@ import styled from "styled-components"
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
import { AIhubmixProvider } from "./providers/AihubmixProvider"
@@ -19,6 +21,7 @@ import { BasetenProvider } from "./providers/BasetenProvider"
import { BedrockProvider } from "./providers/BedrockProvider"
import { CerebrasProvider } from "./providers/CerebrasProvider"
import { ClaudeCodeProvider } from "./providers/ClaudeCodeProvider"
import { ClinePassProvider } from "./providers/ClinePassProvider"
import { ClineProvider } from "./providers/ClineProvider"
import { DeepSeekProvider } from "./providers/DeepSeekProvider"
import { DifyProvider } from "./providers/DifyProvider"
@@ -99,8 +102,9 @@ const ApiOptions = ({
}: ApiOptionsProps) => {
// Use full context state for immediate save payload
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode)
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode, { isClinePassEnabled })
const { handleModeFieldChange } = useApiConfigurationHandlers()
@@ -141,6 +145,9 @@ const ApiOptions = ({
const providerOptions = useMemo(() => {
let providers = PROVIDERS.list
if (!isClinePassEnabled) {
providers = providers.filter((option) => option.value !== "cline-pass")
}
// Filter by platform
if (PLATFORM_CONFIG.type !== PlatformType.VSCODE) {
// Don't include VS Code LM API for non-VSCode platforms
@@ -154,7 +161,7 @@ const ApiOptions = ({
}
return providers
}, [remoteConfigSettings])
}, [isClinePassEnabled, remoteConfigSettings])
const currentProviderLabel = useMemo(() => {
return providerOptions.find((option) => option.value === selectedProvider)?.label || selectedProvider
@@ -363,11 +370,16 @@ const ApiOptions = ({
<ClineProvider
currentMode={currentMode}
initialModelTab={initialModelTab}
isClinePassEnabled={isClinePassEnabled}
isPopup={isPopup}
showModelOptions={showModelOptions}
/>
)}
{apiConfiguration && isClinePassEnabled && selectedProvider === "cline-pass" && (
<ClinePassProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
{apiConfiguration && selectedProvider === "asksage" && (
<AskSageProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
@@ -1,4 +1,4 @@
import { CLAUDE_SONNET_1M_SUFFIX, openRouterDefaultModelId } from "@shared/api"
import { type ApiConfiguration, buildModelInfoNameMap, CLAUDE_SONNET_1M_SUFFIX, type ModelInfo } from "@shared/api"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import { type ClineRecommendedModel, ClineRecommendedModelsResponse } from "@shared/proto/cline/models"
@@ -52,6 +52,12 @@ export interface ClineModelPickerProps {
currentMode: Mode
showProviderRouting?: boolean
initialTab?: "recommended" | "free"
defaultModelId?: string
modelIdFieldPair?: { plan: keyof ApiConfiguration; act: keyof ApiConfiguration }
modelInfoFieldPair?: { plan: keyof ApiConfiguration; act: keyof ApiConfiguration }
models?: Record<string, ModelInfo>
isClinePassEnabled?: boolean
showFeaturedModels?: boolean
}
interface FeaturedModelCardEntry {
@@ -92,11 +98,44 @@ const FREE_MODELS_FALLBACK: FeaturedModelCardEntry[] = CLINE_RECOMMENDED_MODELS_
.map((model) => toFeaturedModelCardEntry(model, "FREE"))
.filter((model): model is FeaturedModelCardEntry => model !== null)
const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMode, showProviderRouting, initialTab }) => {
const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
isPopup,
currentMode,
showProviderRouting,
initialTab,
defaultModelId,
modelIdFieldPair = { plan: "planModeClineModelId", act: "actModeClineModelId" },
modelInfoFieldPair = { plan: "planModeClineModelInfo", act: "actModeClineModelInfo" },
models,
isClinePassEnabled = true,
showFeaturedModels = true,
}) => {
const { handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
const { apiConfiguration, favoritedModelIds, clineModels, refreshClineModels } = useExtensionState()
const { apiConfiguration, favoritedModelIds, clineModels, openRouterModels, refreshClineModels } = useExtensionState()
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
const [searchTerm, setSearchTerm] = useState(modeFields.clineModelId || openRouterDefaultModelId)
const resolvedModels = models ?? clineModels
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
const normalizedSelection = useMemo(
() =>
normalizeApiConfiguration(apiConfiguration, currentMode, {
isClinePassEnabled,
clinePassModelInfoByName: openRouterModelsByName,
}),
[apiConfiguration, currentMode, isClinePassEnabled, openRouterModelsByName],
)
const configuredModelId = apiConfiguration?.[modelIdFieldPair[currentMode]] as string | undefined
const selectedOrDefaultModelId = defaultModelId ?? normalizedSelection.selectedModelId
const resolveModelId = useCallback(
(modelId?: string) => {
if (models && (!modelId || !(modelId in models))) {
return selectedOrDefaultModelId
}
return modelId || selectedOrDefaultModelId
},
[models, selectedOrDefaultModelId],
)
const [searchTerm, setSearchTerm] = useState(resolveModelId(configuredModelId))
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const [clineRecommendedModels, setClineRecommendedModels] = useState<FeaturedModelCardEntry[]>([])
@@ -188,9 +227,9 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
if (initialTab) {
return
}
const currentModelId = modeFields.clineModelId || openRouterDefaultModelId
const currentModelId = resolveModelId(configuredModelId)
setActiveTab(freeClineModelIdSet.has(normalizeModelId(currentModelId)) ? "free" : "recommended")
}, [modeFields.clineModelId, freeClineModelIdSet, initialTab])
}, [configuredModelId, freeClineModelIdSet, initialTab, resolveModelId])
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
@@ -200,19 +239,27 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
handleModeFieldsChange(
{
clineModelId: { plan: "planModeClineModelId", act: "actModeClineModelId" },
clineModelInfo: { plan: "planModeClineModelInfo", act: "actModeClineModelInfo" },
clineModelId: modelIdFieldPair,
clineModelInfo: modelInfoFieldPair,
},
{
clineModelId: newModelId,
clineModelInfo: clineModels?.[newModelId],
clineModelInfo: resolvedModels?.[newModelId],
},
currentMode,
)
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
const selected = normalizeApiConfiguration(apiConfiguration, currentMode)
const resolvedModelId = resolveModelId(configuredModelId)
const selected =
(defaultModelId || models) && resolvedModelId !== normalizedSelection.selectedModelId
? {
...normalizedSelection,
selectedModelId: resolvedModelId,
selectedModelInfo: resolvedModels?.[resolvedModelId] ?? normalizedSelection.selectedModelInfo,
}
: normalizedSelection
if (freeClineModelIdSet.has(normalizeModelId(selected.selectedModelId))) {
return {
...selected,
@@ -226,10 +273,12 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
}
}
return selected
}, [apiConfiguration, currentMode, freeClineModelIdSet])
}, [configuredModelId, defaultModelId, freeClineModelIdSet, models, normalizedSelection, resolvedModels, resolveModelId])
useMount(() => {
refreshClineModels()
if (!models) {
refreshClineModels()
}
})
useEffect(() => {
@@ -238,9 +287,8 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
// Sync external changes when the modelId changes
useEffect(() => {
const currentModelId = modeFields.clineModelId || openRouterDefaultModelId
setSearchTerm(currentModelId)
}, [modeFields.clineModelId])
setSearchTerm(resolveModelId(configuredModelId))
}, [configuredModelId, resolveModelId])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -256,9 +304,9 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
}, [])
const modelIds = useMemo(() => {
const unfilteredModelIds = Object.keys(clineModels ?? {}).sort((a, b) => a.localeCompare(b))
const unfilteredModelIds = Object.keys(resolvedModels ?? {}).sort((a, b) => a.localeCompare(b))
return filterOpenRouterModelIds(unfilteredModelIds, "cline", freeClineModelIds)
}, [clineModels, freeClineModelIds])
}, [resolvedModels, freeClineModelIds])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
@@ -363,7 +411,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
return false
}
return (
Object.entries(clineModels ?? {})?.some(([id, m]) => id === selectedModelId && m.thinkingConfig) ||
Object.entries(resolvedModels ?? {})?.some(([id, m]) => id === selectedModelId && m.thinkingConfig) ||
selectedModelIdLower.includes("claude-haiku-4.5") ||
selectedModelIdLower.includes("claude-4.5-haiku") ||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
@@ -377,7 +425,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
selectedModelIdLower.includes("claude-3.7-sonnet") ||
selectedModelIdLower.includes("claude-3.7-sonnet:thinking")
)
}, [clineModels, selectedModelId, selectedModelIdLower, showReasoningEffort])
}, [resolvedModels, selectedModelId, selectedModelIdLower, showReasoningEffort])
return (
<div style={{ width: "100%", paddingBottom: 2 }}>
@@ -394,49 +442,51 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
<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>
{showFeaturedModels && (
<>
{/* 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>
</>
{/* 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
@@ -0,0 +1,73 @@
import {
buildModelInfoNameMap,
clinePassDefaultModelId,
clinePassModels,
type ModelInfo,
resolveClinePassModelInfo,
} from "@shared/api"
import { EmptyRequest } from "@shared/proto/cline/common"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { ModelsServiceClient } from "@/services/grpc-client"
import ClineModelPicker from "../ClineModelPicker"
import { ClineProvider } from "./ClineProvider"
export const ClinePassProvider: typeof ClineProvider = (props) => {
const { openRouterModels } = useExtensionState()
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
const [clinePassRecommendedModels, setClinePassRecommendedModels] = useState<Record<string, ModelInfo> | undefined>(undefined)
const refreshClinePassModels = useCallback(async () => {
try {
const response = await ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
const models = Object.fromEntries(
(response.clinePass ?? [])
.filter((model) => model.id)
.map((model) => {
// Cline Pass model IDs omit the upstream lab, so look up capabilities using
// the model slug (for example, glm-5.1 instead of cline-pass/glm-5.1).
// If the model is not in OpenRouter yet, use conservative generic defaults
// instead of copying GLM-5.1-specific context/max-token values.
const fallback = resolveClinePassModelInfo(model.id, openRouterModelsByName)
return [
model.id,
{
...fallback,
name: model.name || fallback.name || model.id,
description: model.description || fallback.description,
},
]
}),
)
setClinePassRecommendedModels(Object.keys(models).length > 0 ? models : undefined)
} catch (error) {
console.error("Failed to refresh Cline Pass models:", error)
}
}, [openRouterModelsByName])
useEffect(() => {
void refreshClinePassModels()
}, [refreshClinePassModels])
const clinePassModelOptions = clinePassRecommendedModels ?? clinePassModels
const clinePassDefaultModel = useMemo(() => {
if (!clinePassModelOptions) {
return undefined
}
return clinePassModelOptions[clinePassDefaultModelId]
? clinePassDefaultModelId
: (Object.keys(clinePassModelOptions)[0] ?? clinePassDefaultModelId)
}, [clinePassModelOptions])
return (
<ClineModelPicker
{...props}
defaultModelId={clinePassDefaultModel}
modelIdFieldPair={{ plan: "planModeClinePassModelId", act: "actModeClinePassModelId" }}
modelInfoFieldPair={{ plan: "planModeClinePassModelInfo", act: "actModeClinePassModelInfo" }}
models={clinePassModelOptions}
showFeaturedModels={false}
/>
)
}
@@ -10,12 +10,19 @@ interface ClineProviderProps {
isPopup?: boolean
currentMode: Mode
initialModelTab?: "recommended" | "free"
isClinePassEnabled?: boolean
}
/**
* The Cline provider configuration component
*/
export const ClineProvider = ({ showModelOptions, isPopup, currentMode, initialModelTab }: ClineProviderProps) => {
export const ClineProvider = ({
showModelOptions,
isPopup,
currentMode,
initialModelTab,
isClinePassEnabled,
}: ClineProviderProps) => {
return (
<div>
{/* Cline Account Info Card */}
@@ -24,14 +31,13 @@ export const ClineProvider = ({ showModelOptions, isPopup, currentMode, initialM
</div>
{showModelOptions && (
<>
<ClineModelPicker
currentMode={currentMode}
initialTab={initialModelTab}
isPopup={isPopup}
showProviderRouting={true}
/>
</>
<ClineModelPicker
currentMode={currentMode}
initialTab={initialModelTab}
isClinePassEnabled={isClinePassEnabled}
isPopup={isPopup}
showProviderRouting={true}
/>
)}
</div>
)
@@ -13,6 +13,8 @@ import {
cerebrasModels,
claudeCodeDefaultModelId,
claudeCodeModels,
clinePassDefaultModelId,
clinePassModels,
deepSeekDefaultModelId,
deepSeekModels,
doubaoDefaultModelId,
@@ -59,6 +61,7 @@ import {
qwenCodeModels,
requestyDefaultModelId,
requestyDefaultModelInfo,
resolveClinePassModelInfo,
sambanovaDefaultModelId,
sambanovaModels,
sapAiCoreDefaultModelId,
@@ -102,6 +105,8 @@ export function getModelsForProvider(
return openAiNativeModels
case "openai-codex":
return openAiCodexModels
case "cline-pass":
return clinePassModels
case "deepseek":
return deepSeekModels
case "qwen":
@@ -180,9 +185,11 @@ export interface NormalizedApiConfig {
export function normalizeApiConfiguration(
apiConfiguration: ApiConfiguration | undefined,
currentMode: Mode,
options: { isClinePassEnabled?: boolean; clinePassModelInfoByName?: Record<string, ModelInfo> } = {},
): NormalizedApiConfig {
const provider =
const configuredProvider =
(currentMode === "plan" ? apiConfiguration?.planModeApiProvider : apiConfiguration?.actModeApiProvider) || "anthropic"
const provider = configuredProvider === "cline-pass" && options.isClinePassEnabled === false ? "cline" : configuredProvider
const modelId = currentMode === "plan" ? apiConfiguration?.planModeApiModelId : apiConfiguration?.actModeApiModelId
@@ -280,10 +287,9 @@ export function normalizeApiConfiguration(
currentMode === "plan"
? apiConfiguration?.planModeOpenRouterModelInfo
: apiConfiguration?.actModeOpenRouterModelInfo
const clineModelId =
(currentMode === "plan" ? apiConfiguration?.planModeClineModelId : apiConfiguration?.actModeClineModelId) ||
fallbackOpenRouterModelId ||
openRouterDefaultModelId
const configuredClineModelId =
currentMode === "plan" ? apiConfiguration?.planModeClineModelId : apiConfiguration?.actModeClineModelId
const clineModelId = configuredClineModelId || fallbackOpenRouterModelId || openRouterDefaultModelId
const clineModelInfo =
(currentMode === "plan" ? apiConfiguration?.planModeClineModelInfo : apiConfiguration?.actModeClineModelInfo) ||
fallbackOpenRouterModelInfo ||
@@ -293,6 +299,22 @@ export function normalizeApiConfiguration(
selectedModelId: clineModelId,
selectedModelInfo: clineModelInfo,
}
case "cline-pass":
const configuredClinePassModelId =
currentMode === "plan" ? apiConfiguration?.planModeClinePassModelId : apiConfiguration?.actModeClinePassModelId
const clinePassModelId = configuredClinePassModelId?.startsWith("cline-pass/")
? configuredClinePassModelId
: clinePassDefaultModelId
const clinePassModelInfo =
(currentMode === "plan"
? apiConfiguration?.planModeClinePassModelInfo
: apiConfiguration?.actModeClinePassModelInfo) ||
resolveClinePassModelInfo(clinePassModelId, options.clinePassModelInfoByName)
return {
selectedProvider: provider,
selectedModelId: clinePassModelId,
selectedModelInfo: clinePassModelInfo,
}
case "openai":
const openAiModelId =
currentMode === "plan" ? apiConfiguration?.planModeOpenAiModelId : apiConfiguration?.actModeOpenAiModelId
@@ -532,6 +554,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
openAiModelId: undefined,
openRouterModelId: undefined,
clineModelId: undefined,
clinePassModelId: undefined,
groqModelId: undefined,
basetenModelId: undefined,
huggingFaceModelId: undefined,
@@ -546,6 +569,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
liteLlmModelInfo: undefined,
openRouterModelInfo: undefined,
clineModelInfo: undefined,
clinePassModelInfo: undefined,
requestyModelInfo: undefined,
groqModelInfo: undefined,
basetenModelInfo: undefined,
@@ -577,6 +601,10 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
const clineModelInfo =
(mode === "plan" ? apiConfiguration.planModeClineModelInfo : apiConfiguration.actModeClineModelInfo) ||
openRouterModelInfo
const clinePassModelId =
mode === "plan" ? apiConfiguration.planModeClinePassModelId : apiConfiguration.actModeClinePassModelId
const clinePassModelInfo =
mode === "plan" ? apiConfiguration.planModeClinePassModelInfo : apiConfiguration.actModeClinePassModelInfo
return {
// Core fields
@@ -593,6 +621,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
openAiModelId: mode === "plan" ? apiConfiguration.planModeOpenAiModelId : apiConfiguration.actModeOpenAiModelId,
openRouterModelId,
clineModelId,
clinePassModelId,
groqModelId: mode === "plan" ? apiConfiguration.planModeGroqModelId : apiConfiguration.actModeGroqModelId,
basetenModelId: mode === "plan" ? apiConfiguration.planModeBasetenModelId : apiConfiguration.actModeBasetenModelId,
huggingFaceModelId:
@@ -612,6 +641,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
liteLlmModelInfo: mode === "plan" ? apiConfiguration.planModeLiteLlmModelInfo : apiConfiguration.actModeLiteLlmModelInfo,
openRouterModelInfo,
clineModelInfo,
clinePassModelInfo,
requestyModelInfo:
mode === "plan" ? apiConfiguration.planModeRequestyModelInfo : apiConfiguration.actModeRequestyModelInfo,
groqModelInfo: mode === "plan" ? apiConfiguration.planModeGroqModelInfo : apiConfiguration.actModeGroqModelInfo,
@@ -0,0 +1,12 @@
/**
* Webview-side feature flag identifiers.
*
* Keep these in sync with the `FeatureFlag` enum in
* `apps/vscode/src/shared/services/feature-flags/feature-flags.ts`.
* They are duplicated here as plain string constants because the extension-side
* enum module imports Node/extension-only dependencies that do not resolve in
* the webview bundle.
*/
/** Enables Cline Pass provider/model exposure (settings + onboarding). */
export const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
@@ -777,7 +777,10 @@ export const ExtensionStateContextProvider: React.FC<{
// Auto-refresh Cline models when provider is cline
useEffect(() => {
const hasClineProvider =
state.apiConfiguration?.actModeApiProvider === "cline" || state.apiConfiguration?.planModeApiProvider === "cline"
state.apiConfiguration?.actModeApiProvider === "cline" ||
state.apiConfiguration?.actModeApiProvider === "cline-pass" ||
state.apiConfiguration?.planModeApiProvider === "cline" ||
state.apiConfiguration?.planModeApiProvider === "cline-pass"
if (hasClineProvider && clineModels === null) {
refreshClineModels()
}
+21 -3
View File
@@ -1,4 +1,4 @@
import { ApiConfiguration, ModelInfo, openRouterDefaultModelId } from "@shared/api"
import { ApiConfiguration, clinePassDefaultModelId, clinePassModels, ModelInfo, openRouterDefaultModelId } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { getModeSpecificFields } from "@/components/settings/utils/providerUtils"
@@ -71,6 +71,7 @@ export function validateApiConfiguration(currentMode: Mode, apiConfiguration?: A
}
break
case "cline":
case "cline-pass":
break
case "openai-codex":
// Authentication is handled via OAuth, not API key
@@ -191,7 +192,7 @@ export function validateModelId(
if (apiConfiguration) {
const { apiProvider, openRouterModelId, clineModelId } = getModeSpecificFields(apiConfiguration, currentMode)
switch (apiProvider) {
case "openrouter":
case "openrouter": {
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."
@@ -201,7 +202,8 @@ export function validateModelId(
return "The model ID you provided is not available. Please choose a different model."
}
break
case "cline":
}
case "cline": {
const clineResolvedModelId = clineModelId || openRouterDefaultModelId
if (!clineResolvedModelId) {
return "You must provide a model ID."
@@ -210,6 +212,22 @@ export function validateModelId(
return "The model ID you provided is not available. Please choose a different model."
}
break
}
case "cline-pass": {
const clinePassModelId =
currentMode === "plan" ? apiConfiguration.planModeClinePassModelId : apiConfiguration.actModeClinePassModelId
const clinePassResolvedModelId = clinePassModelId || clinePassDefaultModelId
if (!clinePassResolvedModelId) {
return "You must provide a model ID."
}
if (
!Object.keys(clinePassModels).includes(clinePassResolvedModelId) &&
!clinePassResolvedModelId.startsWith("cline-pass/")
) {
return "The model ID you provided is not available. Please choose a different model."
}
break
}
}
}
return undefined