Compare commits

...
Author SHA1 Message Date
John Choi 0555318b9e Trim redundant comments
Remove comments that restate the code; keep only non-obvious why (403 ordering, config.ts coupling, jsdom quirk).
2026-06-16 19:12:37 -07:00
John Choi 490dca884d Address review: env-aware subscribe URL, friendly copy, and tests
- constants: add getAppBaseUrl(environment) so the subscribe link resolves staging/local/prod even when clineUser.appBaseUrl is missing or stale (keeps APP_BASE_URL as the production default).

- EntitlementError: prefer clineUser.appBaseUrl, then getAppBaseUrl(environment); build the URL with new URL() to avoid slash edge cases. Always show the friendly product headline and surface the backend detail only as muted support text.

- tests: add EntitlementError.test.tsx (prod/staging URL resolution, retry behavior, headline vs backend detail) and a ClineError regression test for the real nested-error provider shape.
2026-06-16 19:03:10 -07:00
John Choi 51265f211c Add shared APP_BASE_URL constant and reuse across webview
Replaces the duplicated 'https://app.cline.bot' literal in EntitlementError, CreditLimitError, and AccountView with a single APP_BASE_URL constant in webview-ui/src/constants.ts. Components still prefer the environment-aware clineUser.appBaseUrl and use the constant only as the production fallback. ChatView is left unchanged since it compares apiBaseUrl (a distinct value).
2026-06-16 18:55:48 -07:00
John Choi 002841c199 Skip auto-retry for Cline Pass entitlement errors and use env-aware subscribe URL
Entitlement (403 ENTITLEMENT_ERROR) failures are non-retryable like auth/spend-limit/quota errors, so exclude them from the auto-retry loop. This stops the raw error JSON + 'Auto-Retry Failed' block from rendering alongside the friendly EntitlementError component.

Also derive the 'Get Cline Pass' subscribe link from the authenticated user's appBaseUrl so it targets the correct environment (e.g. staging-app.cline.bot on staging), falling back to production.
2026-06-16 17:14:05 -07:00
John Choi 7b0b7b7224 Show friendly Cline Pass entitlement error instead of raw JSON
When a Cline Pass model returns a 403 ENTITLEMENT_ERROR (user not subscribed to the required model plan), the chat UI dumped the raw serialized error JSON. Add a dedicated ClineErrorType.Entitlement (detected before the generic 403->Auth path) and a new EntitlementError component that shows a human-readable message, a 'Get Cline Pass' subscribe link, and a Retry button. Includes unit/webview tests and a Storybook story.
2026-06-16 11:11:42 -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
33 changed files with 929 additions and 193 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;
}
}
+13 -2
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 };
}
@@ -2460,6 +2466,9 @@ export class Task {
const quotaExceeded = clineError.isErrorType(
ClineErrorType.QuotaExceeded,
);
const isEntitlementError = clineError.isErrorType(
ClineErrorType.Entitlement,
);
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
const isClineProviderInsufficientCredits = (() => {
@@ -2485,6 +2494,7 @@ export class Task {
!isAuthError &&
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError &&
this.taskState.autoRetryAttempts < 3;
if (shouldRetry) {
// Auto-retry enabled with max 3 attempts: automatically approve the retry
@@ -2546,7 +2556,8 @@ export class Task {
!isClineProviderInsufficientCredits &&
!isAuthError &&
!isSpendLimitError &&
!quotaExceeded;
!quotaExceeded &&
!isEntitlementError;
if (showRetry) {
await this.say(
"error_retry",
@@ -8,6 +8,7 @@ export enum ClineErrorType {
Balance = "balance",
SpendLimit = "spendLimit",
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
}
interface ErrorDetails {
@@ -152,6 +153,11 @@ export class ClineError extends Error {
return ClineErrorType.SpendLimit
}
// Must be checked before the generic auth check since these are returned as 403
if (code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR") {
return ClineErrorType.Entitlement
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
@@ -8,5 +8,50 @@ describe("ClineError", () => {
const err = new ClineError({ message: "Inference cap reached", code: "INFERENCE_CAP_ERROR" })
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded)
})
it("should return Entitlement when code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should return Entitlement when details.code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
details: { code: "ENTITLEMENT_ERROR", message: "Error 403: the user is not subscribed to required model plan" },
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should prefer Entitlement over Auth for 403 ENTITLEMENT_ERROR", () => {
// status 403 would otherwise be classified as Auth; the entitlement code must win.
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should return Entitlement for the real Cline 403 provider error shape (nested error object)", () => {
// ClineError maps `error.error` into `details`, so `details.code` drives classification.
const err = new ClineError(
{
status: 403,
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
},
"cline-pass/glm-5.1",
"cline-pass",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
})
})
+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",
@@ -7,6 +7,7 @@ import deepEqual from "fast-deep-equal"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useInterval } from "react-use"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { APP_BASE_URL } from "@/constants"
import { type ClineUser, handleSignOut } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AccountServiceClient } from "@/services/grpc-client"
@@ -229,7 +230,7 @@ export const ClineAccountView = ({ clineUser, userOrganizations, activeOrganizat
fetchCreditBalance(dropdownValue)
}, 60000)
const clineUrl = appBaseUrl || "https://app.cline.bot"
const clineUrl = appBaseUrl || APP_BASE_URL
// Fetch balance on mount
useEffect(() => {
@@ -2,6 +2,7 @@ import { AskResponseRequest } from "@shared/proto/cline/task"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useMemo, useState } from "react"
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
import { APP_BASE_URL } from "@/constants"
import { useClineAuth } from "@/context/ClineAuthContext"
import { AccountServiceClient, TaskServiceClient } from "@/services/grpc-client"
@@ -14,8 +15,8 @@ interface CreditLimitErrorProps {
}
const DEFAULT_BUY_CREDITS_URL = {
USER: "https://app.cline.bot/dashboard/account?tab=credits&redirect=true",
ORG: "https://app.cline.bot/dashboard/organization?tab=credits&redirect=true",
USER: `${APP_BASE_URL}/dashboard/account?tab=credits&redirect=true`,
ORG: `${APP_BASE_URL}/dashboard/organization?tab=credits&redirect=true`,
}
const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
@@ -0,0 +1,71 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import EntitlementError from "./EntitlementError"
// Mocks are mutated per-test to simulate different auth/environment states.
const mockAuth: { clineUser: { appBaseUrl?: string } | null } = { clineUser: null }
const mockExtensionState: { environment?: string } = { environment: undefined }
vi.mock("@/context/ClineAuthContext", () => ({
useClineAuth: () => mockAuth,
}))
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => mockExtensionState,
}))
const askResponseMock = vi.fn()
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
askResponse: (...args: unknown[]) => askResponseMock(...args),
},
}))
const getSubscribeHref = () => screen.getByRole("link", { name: /get cline pass/i }).getAttribute("href")
describe("EntitlementError", () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuth.clineUser = null
mockExtensionState.environment = undefined
})
it("shows the friendly headline regardless of the backend message", () => {
render(<EntitlementError message="Error 403: the user is not subscribed to required model plan" />)
expect(screen.getByText("This model requires a Cline Pass subscription.")).toBeInTheDocument()
})
it("surfaces the backend detail as muted support text when it differs from the headline", () => {
render(<EntitlementError message="Error 403: the user is not subscribed to required model plan" />)
expect(screen.getByText("Error 403: the user is not subscribed to required model plan")).toBeInTheDocument()
})
it("does not duplicate the headline when no backend detail is provided", () => {
render(<EntitlementError />)
expect(screen.getAllByText("This model requires a Cline Pass subscription.")).toHaveLength(1)
})
it("defaults the subscribe link to production when no auth/environment is available", () => {
render(<EntitlementError />)
expect(getSubscribeHref()).toBe("https://app.cline.bot/dashboard/subscription")
})
it("prefers the authenticated user's app base URL (staging)", () => {
mockAuth.clineUser = { appBaseUrl: "https://staging-app.cline.bot" }
render(<EntitlementError />)
expect(getSubscribeHref()).toBe("https://staging-app.cline.bot/dashboard/subscription")
})
it("falls back to the current environment when the user app base URL is unavailable (staging)", () => {
mockExtensionState.environment = "staging"
render(<EntitlementError />)
expect(getSubscribeHref()).toBe("https://staging-app.cline.bot/dashboard/subscription")
})
it("sends yesButtonClicked when Retry Request is clicked", () => {
render(<EntitlementError />)
// VSCodeButton has no ARIA role in jsdom; click by label text instead.
fireEvent.click(screen.getByText("Retry Request"))
expect(askResponseMock).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,65 @@
import { AskResponseRequest } from "@shared/proto/cline/task"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React from "react"
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
import { getAppBaseUrl } from "@/constants"
import { useClineAuth } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { TaskServiceClient } from "@/services/grpc-client"
interface EntitlementErrorProps {
message?: string
}
const CLINE_PASS_SUBSCRIBE_PATH = "/dashboard/subscription"
const HEADLINE = "This model requires a Cline Pass subscription."
const EntitlementError: React.FC<EntitlementErrorProps> = ({ message }) => {
const { clineUser } = useClineAuth()
const { environment } = useExtensionState()
const appBaseUrl = clineUser?.appBaseUrl || getAppBaseUrl(environment)
const subscribeUrl = new URL(CLINE_PASS_SUBSCRIBE_PATH, appBaseUrl).toString()
const backendDetail = message && message !== HEADLINE ? message : undefined
return (
<div className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)">
<div className="mb-3">
<div className="text-error mb-2">{HEADLINE}</div>
<div className="text-(--vscode-descriptionForeground) text-xs">
Subscribe to Cline Pass to use this model, then retry your request.
</div>
{backendDetail && (
<div className="text-(--vscode-descriptionForeground) text-xs mt-1 opacity-80 wrap-anywhere">
{backendDetail}
</div>
)}
</div>
<VSCodeButtonLink className="w-full mb-2" href={subscribeUrl}>
<span className="codicon codicon-rocket mr-[6px] text-[14px]" />
Get Cline Pass
</VSCodeButtonLink>
<VSCodeButton
appearance="secondary"
className="w-full"
onClick={async () => {
try {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
} catch (error) {
console.error("Error invoking action:", error)
}
}}>
<span className="codicon codicon-refresh mr-1.5" />
Retry Request
</VSCodeButton>
</div>
)
}
export default EntitlementError
@@ -214,6 +214,32 @@ export const ClineSpendLimitMinimal: Story = {
},
}
// Cline Pass entitlement error (user not subscribed to a required model plan)
export const ClinePassEntitlementError: Story = {
args: {
message: createMockMessage(),
errorType: "error",
apiRequestFailedMessage: JSON.stringify({
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
code: "ENTITLEMENT_ERROR",
modelId: "cline-pass/glm-5.1",
providerId: "cline-pass",
details: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
}),
},
parameters: {
docs: {
description: {
story: "Cline Pass model returns a 403 ENTITLEMENT_ERROR when the user is not subscribed. Instead of dumping the raw JSON blob, a human-readable message with a 'Get Cline Pass' subscribe link and a retry button is shown.",
},
},
},
}
// Authentication-related errors with configurable scenarios
export const AuthenticationErrors: Story = {
args: {
@@ -19,6 +19,11 @@ vi.mock("@/components/chat/CreditLimitError", () => ({
default: ({ message }: { message: string }) => <div data-testid="credit-limit-error">{message}</div>,
}))
// Mock EntitlementError component
vi.mock("@/components/chat/EntitlementError", () => ({
default: ({ message }: { message: string }) => <div data-testid="entitlement-error">{message}</div>,
}))
// Mock ClineError
vi.mock("../../../../src/services/error/ClineError", () => ({
ClineError: {
@@ -28,6 +33,7 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
Balance: "balance",
RateLimit: "rateLimit",
Auth: "auth",
Entitlement: "entitlement",
},
}))
@@ -128,6 +134,38 @@ describe("ErrorRow", () => {
expect(screen.getByText("Inference cap reached")).toBeInTheDocument()
})
it("renders entitlement error with the detail message instead of a raw JSON blob", async () => {
const mockClineError = {
message: "403 Error 403: the user is not subscribed to required model plan",
isErrorType: vi.fn((type) => type === "entitlement"),
providerId: "cline-pass",
_error: {
code: "ENTITLEMENT_ERROR",
details: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(
<ErrorRow
apiRequestFailedMessage='{"message":"403 Error 403...","code":"ENTITLEMENT_ERROR"}'
errorType="error"
message={mockMessage}
/>,
)
// Renders the friendly EntitlementError component with the human-readable detail message...
expect(screen.getByTestId("entitlement-error")).toBeInTheDocument()
expect(screen.getByText("Error 403: the user is not subscribed to required model plan")).toBeInTheDocument()
// ...and does not dump the raw JSON blob or the [CLINE-PASS] ENTITLEMENT_ERROR header.
expect(screen.queryByText(/ENTITLEMENT_ERROR/)).not.toBeInTheDocument()
})
it("renders friendly logged-out message and sign in button when user is not signed in", async () => {
const mockClineError = {
message: "Authentication failed",
@@ -1,6 +1,7 @@
import { ClineMessage } from "@shared/ExtensionMessage"
import { memo } from "react"
import CreditLimitError from "@/components/chat/CreditLimitError"
import EntitlementError from "@/components/chat/EntitlementError"
import SpendLimitError from "@/components/chat/SpendLimitError"
import { Button } from "@/components/ui/button"
import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext"
@@ -61,6 +62,11 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
)
}
if (clineError?.isErrorType(ClineErrorType.Entitlement)) {
const detailMessage = clineError?._error?.details?.message || errorMessage
return <EntitlementError message={detailMessage} />
}
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
@@ -10,6 +10,7 @@ import { normalizeApiConfiguration } from "@/components/settings/utils/providerU
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
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 +20,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"
@@ -56,6 +58,8 @@ import { XaiProvider } from "./providers/XaiProvider"
import { ZAiProvider } from "./providers/ZAiProvider"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
interface ApiOptionsProps {
showModelOptions: boolean
apiErrorMessage?: string
@@ -99,8 +103,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 +146,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 +162,7 @@ const ApiOptions = ({
}
return providers
}, [remoteConfigSettings])
}, [isClinePassEnabled, remoteConfigSettings])
const currentProviderLabel = useMemo(() => {
return providerOptions.find((option) => option.value === selectedProvider)?.label || selectedProvider
@@ -363,11 +371,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,
+16
View File
@@ -1,6 +1,22 @@
import { Environment } from "../../src/shared/config-types"
export const LINKS = {
DOCUMENTATION: {
REMOTE_MCP_SERVER_DOCS: "https://docs.cline.bot/mcp/connecting-to-a-remote-server",
LOCAL_MCP_SERVER_DOCS: "https://docs.cline.bot/mcp/configuring-mcp-servers#editing-mcp-settings-files",
},
}
export const APP_BASE_URL = "https://app.cline.bot"
// Keep in sync with ClineEndpoint.getEnvironment() in apps/vscode/src/config.ts.
const APP_BASE_URL_BY_ENVIRONMENT: Record<Environment, string> = {
[Environment.production]: APP_BASE_URL,
[Environment.staging]: "https://staging-app.cline.bot",
[Environment.local]: "http://localhost:3000",
[Environment.selfHosted]: APP_BASE_URL,
}
export function getAppBaseUrl(environment?: Environment): string {
return (environment && APP_BASE_URL_BY_ENVIRONMENT[environment]) || APP_BASE_URL
}
@@ -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