Compare commits

...

30 Commits

Author SHA1 Message Date
Saoud Rizwan e867f4520b chore(vscode): bump to 4.0.7 for legacy release 2026-07-08 19:52:46 -07:00
Saoud Rizwan a2ca96cae1 feat(vscode): add ClinePass limit error (legacy) (#12179)
* feat(vscode): add ClinePass limit error with switch to usage-based billing

* refactor(vscode): simplify ClinePass limit message guards
2026-07-08 16:08:35 -07:00
Saoud Rizwan edc088fe5f fix(vscode): update ClinePass onboarding option copy (#12167)
* fix(vscode): update ClinePass onboarding option copy

* fix(vscode): use link foreground color for ClinePass learn more link

* fix(vscode): open ClinePass learn more link via openUrl RPC

* fix(vscode): match ClinePass learn more link font size to description
2026-07-08 13:08:17 -07:00
Tomás Barreiro 720cf5ff3f Remove all references to glm 5.1 (#12106)
* remove all references to glm 5.1

* Update other references

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-08 12:17:21 -07:00
Ara 3037b393a8 Remove Cline model picker recommendation copy (#12169) 2026-07-08 12:15:26 -07:00
Saoud Rizwan 7eb7897ff2 feat(vscode): allow selecting Cline free models on ClinePass provider (#12131)
* feat(vscode): allow selecting Cline free models on ClinePass provider

* feat(vscode): show ClinePass models as Subscribed/Free tabs

* feat(vscode): simplify ClinePass subscribed model cards

* fix(vscode): update ClinePass free tab copy

* fix(vscode): label free models with cline provider in chat model badge

* chore(vscode): revert ClinePass route copy changes

* feat(vscode): show descriptions on ClinePass subscribed model cards

* fix(vscode): keep catalog descriptions in ClinePass model info panel
2026-07-08 11:58:56 -07:00
Saoud Rizwan 797565f203 fix(vscode): clarify ClinePass description in provider settings (#12103) 2026-07-06 16:59:29 -07:00
Saoud Rizwan 3d312a4bb8 chore(vscode): drop reverted telemetry entry from 4.0.6 changelog 2026-07-02 10:43:54 -07:00
Saoud Rizwan aa3f38d1d9 Revert "Add diff view telemetry metrics (#12017)"
This reverts commit 264e2c9835.
2026-07-02 10:43:24 -07:00
Saoud Rizwan 302967f679 test(vscode): await extension activation in plus button test to fix flaky CI failure 2026-07-02 10:30:48 -07:00
Saoud Rizwan 8c859c182c chore(vscode): bump to 4.0.6 for legacy release 2026-07-02 10:15:55 -07:00
cline-bot 83e062e743 fix(vscode): resolve legacy-extension lint failures blocking legacy build
biome lint (via bun run lint) fails on the legacy-extension branch with
two pre-existing issues:

- hook-factory.test.ts registers two separate beforeEach hooks
  (lint/suspicious/noDuplicateTestHooks). Merge the Windows timeout
  guard into the primary beforeEach.
- integration.test.ts exports mockProviderInfo from a *.test.ts file
  (lint/suspicious/noExportsInTest), which four sibling test files
  import. Move the fixture into a new non-test helper file,
  test-fixtures.ts (mirroring the existing matcher-test.ts pattern in
  the same directory), and update the importers.

No behavior change; verified bun run check-types, bun run lint, and
bun run compile-standalone all pass after this change.
2026-07-02 06:00:51 -04:00
Max 264e2c9835 Add diff view telemetry metrics (#12017)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-01 11:27:44 -07:00
Saoud Rizwan 81a6fba11c fix: generalize model capability warning (#12005) 2026-06-30 13:01:39 -07:00
Saoud Rizwan 9da3c59553 fix(vscode): update Claude Sonnet 5 pricing (#12009)
* fix(vscode): update Claude Sonnet 5 pricing

* fix(vscode): restore Claude Sonnet 4.6 pricing
2026-06-30 12:48:11 -07:00
Saoud Rizwan 0e01b99e76 chore(vscode): bump to 4.0.5 for legacy release 2026-06-30 12:28:05 -07:00
Saoud Rizwan ba5f22f58c feat(vscode): add Claude Sonnet 5 support (#12007) 2026-06-30 12:26:30 -07:00
Saoud Rizwan 1cbfc7197e chore(vscode): bump to 4.0.4 for legacy release 2026-06-29 13:15:03 -07:00
Tomás Barreiro 4d8f9ea1a3 Remove feature flag from the extension (#11987)
* Remove feature flag from the extension

* fix(vscode): preserve ClinePass provider metadata

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-29 13:07:42 -07:00
Saoud Rizwan 40b0b0c236 chore(vscode): bump to 4.0.3 for legacy release 2026-06-29 10:20:41 -07:00
Tomás Barreiro 8c5e1289bf Remove handler flag (#11978) 2026-06-29 10:15:45 -07:00
Saoud Rizwan 4cddde58ed chore(vscode): bump to 4.0.2 for legacy release 2026-06-28 20:54:04 -07:00
Saoud Rizwan 9d45accc3b fix: default focus chain settings in webview state (#11960)
* fix: default focus chain settings in webview state

* fix: default missing global settings from state manager

* Revert "fix: default missing global settings from state manager"

This reverts commit 76a628689e.
2026-06-28 20:52:52 -07:00
Saoud Rizwan 57760c1e20 fix: polish ClinePass and Z AI model metadata (#11958) 2026-06-28 20:11:11 -07:00
Tomás Barreiro fc33e8dbd9 Fix webview env replacing (#11955) 2026-06-28 18:37:02 -07:00
Saoud Rizwan b1dcaf576a fix(vscode): align ClinePass model resolution and reasoning controls (#11954)
* fix(vscode): show reasoning effort for ClinePass models

* fix(vscode): centralize reasoning effort support

* fix(vscode): normalize Cline recommended Z.ai ids

* fix(vscode): preserve Claude budget reasoning include flag

* fix(vscode): simplify ClinePass reasoning support

* fix(vscode): simplify reasoning effort model detection

* chore(vscode): remove unused ClinePass helper

* chore(vscode): avoid mutating ClinePass model info
2026-06-28 18:17:15 -07:00
Saoud Rizwan 419f2cea73 feat(vscode): improve ClinePass provider UX (#11950)
* feat(vscode): improve ClinePass provider UX

* fix(vscode): handle ClinePass action fallback
2026-06-28 16:33:44 -07:00
Saoud Rizwan 54d8c695a3 fix(vscode): prefer canonical Cline Z.ai model ids (#11951)
* fix(vscode): prefer canonical Cline Z.ai model ids

* fix(vscode): avoid redundant Cline model alias filtering
2026-06-28 16:13:04 -07:00
Saoud Rizwan 5a62ab8564 feat(deepseek): support reasoning effort (incl. xhigh) for thinking models (#11938)
* feat(deepseek): support reasoning effort (incl. xhigh) for thinking models

* fix(deepseek): don't forward 'none' reasoning effort to API

* fix(deepseek): scope reasoning effort selector
2026-06-28 10:55:26 -07:00
Saoud Rizwan f81afb51b5 chore(vscode): bump to 4.0.1 for legacy rollback release 2026-06-27 19:09:05 -07:00
71 changed files with 2169 additions and 372 deletions
+60
View File
@@ -1,5 +1,65 @@
# Changelog
## [4.0.7]
### Added
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
### Changed
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
- Remove the Cline model picker recommendation copy.
### Removed
- Remove all references to GLM 5.1.
## [4.0.6]
### Fixed
- Generalize the model capability warning so it applies more broadly.
## [4.0.5]
### Added
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
## [4.0.4]
### Changed
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### Fixed
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [3.89.2]
### Fixed
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
expect(request.model).toBe("cline-pass/glm-5.2");
});
it("uses auth material resolved by provider settings manager", async () => {
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
expect(request.model).toBe("cline-pass/glm-5.2");
});
});
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.89.2",
"version": "4.0.7",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -0,0 +1,104 @@
import "should"
import {
type ApiConfiguration,
clinePassDefaultModelId,
clinePassModelInfoSaneDefaults,
clinePassModels,
type ModelInfo,
} from "@shared/api"
import type { Mode } from "@shared/storage/types"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { buildApiHandler } from "../index"
describe("buildApiHandler", () => {
beforeEach(() => {
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
sinon.stub(AuthService, "getInstance").returns({} as any)
})
afterEach(() => {
sinon.restore()
})
const buildClinePassHandler = (configuration: Partial<ApiConfiguration>, mode: Mode = "act") =>
buildApiHandler(
{
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
...configuration,
} as ApiConfiguration,
mode,
)
describe("cline-pass provider", () => {
const freeModelInfo: ModelInfo = {
...clinePassModelInfoSaneDefaults,
maxTokens: 32_768,
contextWindow: 256_000,
description: "A free model",
}
it("passes a free (non cline-pass prefixed) model id through with its stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
actModeClinePassModelInfo: freeModelInfo,
})
const model = handler.getModel()
model.id.should.equal("kwaipilot/kat-coder-pro")
model.info.should.deepEqual(freeModelInfo)
})
it("passes a :free suffixed model id through with its stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "arcee-ai/trinity-large-preview:free",
actModeClinePassModelInfo: freeModelInfo,
})
const model = handler.getModel()
model.id.should.equal("arcee-ai/trinity-large-preview:free")
model.info.should.deepEqual(freeModelInfo)
})
it("falls back to sane defaults for a free model id without stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
})
const model = handler.getModel()
model.id.should.equal("kwaipilot/kat-coder-pro")
model.info.should.deepEqual(clinePassModelInfoSaneDefaults)
})
it("resolves cline-pass ids against the static model table", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "cline-pass/glm-5.2",
})
const model = handler.getModel()
model.id.should.equal("cline-pass/glm-5.2")
model.info.should.deepEqual(clinePassModels["cline-pass/glm-5.2"])
})
it("falls back to the default pass model when no model id is configured", () => {
const handler = buildClinePassHandler({})
const model = handler.getModel()
model.id.should.equal(clinePassDefaultModelId)
model.info.should.deepEqual(clinePassModels[clinePassDefaultModelId])
})
it("resolves plan and act mode model ids independently", () => {
const configuration: Partial<ApiConfiguration> = {
planModeClinePassModelId: "kwaipilot/kat-coder-pro",
planModeClinePassModelInfo: freeModelInfo,
actModeClinePassModelId: "cline-pass/glm-5.2",
}
buildClinePassHandler(configuration, "plan").getModel().id.should.equal("kwaipilot/kat-coder-pro")
buildClinePassHandler(configuration, "act").getModel().id.should.equal("cline-pass/glm-5.2")
})
})
})
+19 -11
View File
@@ -1,8 +1,13 @@
import { ApiConfiguration, clinePassDefaultModelId, ModelInfo, QwenApiRegions, resolveClinePassModelInfo } from "@shared/api"
import {
ApiConfiguration,
buildModelInfoNameMap,
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"
@@ -80,10 +85,7 @@ function createHandlerForProvider(
options: Omit<ApiConfiguration, "apiProvider">,
mode: Mode,
): ApiHandler {
const effectiveApiProvider =
apiProvider === "cline-pass" && !featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS) ? "cline" : apiProvider
switch (effectiveApiProvider) {
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -204,6 +206,7 @@ function createHandlerForProvider(
onRetryAttempt: options.onRetryAttempt,
deepSeekApiKey: options.deepSeekApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
})
case "requesty":
return new RequestyHandler({
@@ -289,10 +292,15 @@ function createHandlerForProvider(
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)
// ClinePass users may also select Cline free models (OpenRouter-style ids without
// the cline-pass/ prefix), so pass any configured id through to the Cline API.
const clineModelId = configuredClinePassModelId || clinePassDefaultModelId
const clineModelInfo = resolveClinePassModelInfo(
clineModelId,
configuredClinePassModelInfo
? buildModelInfoNameMap({ [clineModelId]: configuredClinePassModelInfo })
: undefined,
)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
clineAccountId: options.clineAccountId,
@@ -220,6 +220,11 @@ describe("AwsBedrockHandler", () => {
bedrockModels["anthropic.claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should mark Bedrock Sonnet 5 variants as global-endpoint capable", () => {
bedrockModels["anthropic.claude-sonnet-5"].supportsGlobalEndpoint.should.equal(true)
bedrockModels["anthropic.claude-sonnet-5:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should include Vertex Opus 4.7 variants in the derived global model list", () => {
vertexModels["claude-opus-4-7"].supportsGlobalEndpoint.should.equal(true)
vertexModels["claude-opus-4-7:1m"].supportsGlobalEndpoint.should.equal(true)
@@ -990,6 +995,19 @@ describe("AwsBedrockHandler", () => {
modelId.should.equal("jp.anthropic.claude-sonnet-4-6")
})
it("should apply JP cross-region prefix for sonnet 5", async () => {
const jpOptions: AwsBedrockHandlerOptions = {
...mockOptions,
awsUseCrossRegionInference: true,
apiModelId: "anthropic.claude-sonnet-5",
awsRegion: "ap-northeast-1",
}
const jpHandler = new AwsBedrockHandler(jpOptions)
const modelId = await jpHandler.getModelId()
modelId.should.equal("jp.anthropic.claude-sonnet-5")
})
it("should apply global cross-region prefix for supported models", async () => {
const globalOptions: AwsBedrockHandlerOptions = {
...mockOptions,
@@ -426,6 +426,26 @@ describe("ClaudeCodeHandler", () => {
model.info.contextWindow.should.equal(200_000)
})
it("should support Sonnet 5 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-5",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-5")
model.info.contextWindow.should.equal(200_000)
})
it("should support Sonnet 5 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-5[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-5[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Fable 5 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-fable-5[1m]",
@@ -180,7 +180,7 @@ describe("ClineHandler", () => {
const fakeClient = { chat: { completions: { create: sinon.stub().rejects(apiError) } } }
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.1", info: openRouterDefaultModelInfo })
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.2", info: openRouterDefaultModelInfo })
let thrown: unknown
try {
@@ -191,7 +191,7 @@ describe("ClineHandler", () => {
thrown = e
}
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.1", "cline-pass")
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.2", "cline-pass")
clineError.isErrorType(ClineErrorType.Entitlement).should.be.true()
})
})
@@ -69,6 +69,7 @@ describe("SapAiCoreHandler", () => {
it("should support different Claude model variants", () => {
const modelVariants = [
"anthropic--claude-sonnet-5",
"anthropic--claude-4.6-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
@@ -134,9 +134,11 @@ interface ProviderChainOptions {
profile?: string
}
// a special jp inference profile was created for sonnet 4.6, opus 4.6, sonnet 4.5 & haiku 4.5
// a special jp inference profile was created for newer Claude CRIS models
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
const JP_SUPPORTED_CRIS_MODELS = [
"anthropic.claude-sonnet-5",
"anthropic.claude-sonnet-5:1m",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-sonnet-4-6:1m",
"anthropic.claude-opus-4-6-v1",
+10 -1
View File
@@ -1,7 +1,10 @@
import { DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import type {
ChatCompletionReasoningEffort,
ChatCompletionTool as OpenAITool,
} from "openai/resources/chat/completions"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
@@ -15,6 +18,7 @@ import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-p
interface DeepSeekHandlerOptions extends CommonApiHandlerOptions {
deepSeekApiKey?: string
apiModelId?: string
reasoningEffort?: string
}
export class DeepSeekHandler implements ApiHandler {
@@ -98,6 +102,11 @@ export class DeepSeekHandler implements ApiHandler {
stream_options: { include_usage: true },
// Only set temperature for non-thinking models
...(isDeepSeekThinkingModel ? {} : { temperature: 0 }),
// DeepSeek thinking models accept reasoning effort (low/medium map to high, xhigh maps to max).
// "none" isn't a valid DeepSeek value, so omit it and let the API use its default.
...(isDeepSeekThinkingModel && this.options.reasoningEffort && this.options.reasoningEffort !== "none"
? { reasoning_effort: this.options.reasoningEffort as ChatCompletionReasoningEffort }
: {}),
...getOpenAIToolParams(tools),
})
@@ -672,6 +672,7 @@ export class SapAiCoreHandler implements ApiHandler {
const anthropicModels = [
"anthropic--claude-4.5-haiku",
"anthropic--claude-sonnet-5",
"anthropic--claude-4.7-opus",
"anthropic--claude-4.6-opus",
"anthropic--claude-4.5-opus",
@@ -725,6 +726,7 @@ export class SapAiCoreHandler implements ApiHandler {
"amazon--nova-micro",
];
const converseStreamModels = [
"anthropic--claude-sonnet-5",
"anthropic--claude-4.7-opus",
"anthropic--claude-4.6-opus",
"anthropic--claude-4.5-opus",
@@ -78,6 +78,19 @@ describe("createOpenRouterStream", () => {
payload.should.not.have.property("max_tokens")
})
it("strips the custom Sonnet 5 1m suffix and pins Anthropic/Vertex routing", async () => {
const { client, create } = createClient()
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
id: "anthropic/claude-sonnet-5:1m",
info: createModelInfo(128_000),
})
const payload = create.firstCall.args[0] as any
payload.should.have.property("model", "anthropic/claude-sonnet-5")
payload.provider.should.deepEqual({ order: ["anthropic", "google-vertex/global"], allow_fallbacks: false })
})
it("adds cache_control blocks for Qwen models that require explicit OpenRouter caching", async () => {
for (const modelId of ["qwen/qwen3.6-plus", "qwen/qwen3.7-max"]) {
const { client, create } = createClient()
@@ -114,4 +127,87 @@ describe("createOpenRouterStream", () => {
should(payload.temperature).equal(undefined)
should(payload.top_p).equal(undefined)
})
it("includes reasoning for Claude budget-based reasoning models", async () => {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: "anthropic/claude-sonnet-4.5",
info: createModelInfo(64_000),
},
undefined,
16_384,
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ max_tokens: 16_384 })
should(payload.temperature).equal(undefined)
})
it("sends reasoning effort instead of token budgets for supported OpenRouter/Cline model families", async () => {
for (const modelId of [
"zai/glm-5.2",
"z-ai/glm-5.2",
"moonshotai/kimi-k2-thinking",
"accounts/fireworks/models/minimax-m3",
"provider/mimo-vl",
"qwen/qwen3.7-max",
"deepseek/deepseek-r1",
]) {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: modelId,
info: { ...createModelInfo(131_072), thinkingConfig: { maxBudget: 16_384 } },
},
"high",
16_384,
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ effort: "high" })
}
})
it("does not send reasoning effort for ClinePass requests when unset", async () => {
const { client, create } = createClient()
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
id: "cline-pass/glm-5.2",
info: createModelInfo(131_072),
})
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.should.not.have.property("reasoning")
})
it("sends the selected reasoning effort for ClinePass requests", async () => {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: "cline-pass/glm-5.2",
info: createModelInfo(131_072),
},
"high",
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ effort: "high" })
})
})
@@ -10,6 +10,7 @@ import {
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet51mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
@@ -62,6 +63,7 @@ export async function createOpenRouterStream(
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeSonnet51mModelId ||
model.id === openRouterClaudeOpus461mModelId ||
model.id === openRouterClaudeOpus471mModelId ||
model.id === openRouterClaudeOpus481mModelId ||
@@ -147,6 +149,8 @@ export async function createOpenRouterStream(
switch (model.id) {
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-5":
case "anthropic/claude-5-sonnet":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
@@ -9,6 +9,7 @@ import {
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet51mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
@@ -38,6 +39,7 @@ export async function createVercelAIGatewayStream(
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeSonnet51mModelId ||
model.id === openRouterClaudeOpus461mModelId ||
model.id === openRouterClaudeOpus471mModelId ||
model.id === openRouterClaudeOpus481mModelId ||
+3 -3
View File
@@ -11,6 +11,7 @@ import { McpHub } from "@services/mcp/McpHub"
import type { ApiProvider, ModelInfo } from "@shared/api"
import type { ChatContent } from "@shared/ChatContent"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
import type { HistoryItem } from "@shared/HistoryItem"
import type { McpMarketplaceCatalog, McpMarketplaceItem } from "@shared/mcp"
import { type Settings } from "@shared/storage/state-keys"
@@ -523,8 +524,7 @@ export class Controller {
// On login we route the user to the managed "cline" provider, but preserve a
// "cline-pass" selection made during onboarding (otherwise it would be clobbered).
// A "cline-pass" provider can only be set when the ext-cline-pass flag is on, so
// non-ClinePass logins are unaffected.
// Non-ClinePass logins are unaffected because they do not persist "cline-pass".
const planProvider: ApiProvider =
currentApiConfiguration.planModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
const actProvider: ApiProvider = currentApiConfiguration.actModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
@@ -856,7 +856,7 @@ export class Controller {
const taskHistory = this.stateManager.getGlobalStateKey("taskHistory")
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings") ?? DEFAULT_FOCUS_CHAIN_SETTINGS
const preferredLanguage = this.stateManager.getGlobalSettingsKey("preferredLanguage")
const mode = this.stateManager.getGlobalSettingsKey("mode")
const strictPlanModeEnabled = this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled")
@@ -135,4 +135,72 @@ describe("refreshClineModels", () => {
expect(fable1m.contextWindow).to.equal(1_000_000)
expect(fable1m.tiers).to.not.equal(undefined)
})
it("prefers Vercel-style Z.ai IDs when the Cline model list includes OpenRouter aliases", async () => {
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").callsFake((flag) => {
return flag === FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT
})
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(StateManager, "get").returns({
getModelsCache: () => null,
setModelsCache: () => {},
} as unknown as StateManager)
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
sandbox.stub(axios, "get").resolves({
data: {
data: [
{
id: "z-ai/glm-5.2",
name: "OpenRouter GLM 5.2",
description: "OpenRouter alias",
context_length: 128_000,
top_provider: {
max_completion_tokens: 8_192,
context_length: 128_000,
is_moderated: false,
},
architecture: {
modality: "text->text",
},
pricing: {
prompt: "0.00000098",
completion: "0.00000308",
},
supported_parameters: ["include_reasoning", "reasoning"],
},
{
id: "zai/glm-5.2",
name: "GLM 5.2",
description: "Vercel canonical ID",
context_length: 1_000_000,
top_provider: {
max_completion_tokens: 131_072,
context_length: 1_000_000,
is_moderated: false,
},
architecture: {
modality: "text->text",
},
pricing: {
prompt: "0.0000015",
completion: "0.0000045",
},
supported_parameters: ["include_reasoning", "reasoning"],
},
],
},
})
const models = await refreshClineModels({} as Controller)
expect(models["zai/glm-5.2"]).to.not.equal(undefined)
expect(models["zai/glm-5.2"].contextWindow).to.equal(1_000_000)
expect(models["z-ai/glm-5.2"]).to.equal(undefined)
})
})
@@ -39,7 +39,7 @@ describe("refreshClineRecommendedModels", () => {
data: {
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
id: "anthropic/claude-sonnet-5",
description: "Remote recommended",
tags: ["NEW"],
},
@@ -61,8 +61,8 @@ describe("refreshClineRecommendedModels", () => {
expect(result).to.deep.equal({
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
id: "anthropic/claude-sonnet-5",
name: "anthropic/claude-sonnet-5",
description: "Remote recommended",
tags: ["NEW"],
},
@@ -127,4 +127,99 @@ describe("refreshClineRecommendedModels", () => {
expect(axiosGetStub.calledOnce).to.equal(true);
expect(secondResult).to.deep.equal(firstResult);
});
it("normalizes Cline provider Z.ai recommended IDs to the Cline API alias", async () => {
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
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(axios, "get").resolves({
data: {
recommended: [
{
id: "zai/glm-5.2",
name: "zai/glm-5.2",
description: "Recommended GLM",
},
],
free: [
{
id: "zai/free-glm",
description: "Free GLM",
},
],
},
});
const result = await refreshClineRecommendedModels();
expect(result.recommended[0]).to.include({
id: "z-ai/glm-5.2",
name: "z-ai/glm-5.2",
});
expect(result.free[0]).to.include({
id: "z-ai/free-glm",
name: "z-ai/free-glm",
});
});
it("normalizes cached Cline provider Z.ai recommended IDs", async () => {
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
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(axios, "get").rejects(new Error("network unavailable"));
sandbox.stub(fs, "access").resolves();
sandbox.stub(fs, "readFile").resolves(
JSON.stringify({
recommended: [
{
id: "zai/glm-5.2",
name: "zai/glm-5.2",
},
],
}),
);
const result = await refreshClineRecommendedModels();
expect(result.recommended.map((model) => model.id)).to.deep.equal(["z-ai/glm-5.2"]);
expect(result.recommended.map((model) => model.name)).to.deep.equal(["z-ai/glm-5.2"]);
});
it("prefers canonical ClinePass Z.ai IDs when aliases are also present", async () => {
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
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(axios, "get").resolves({
data: {
clinePass: [
{
id: "cline-pass/z-ai/glm-5.2",
description: "OpenRouter alias",
},
{
id: "cline-pass/zai/glm-5.2",
description: "Canonical ID",
},
],
},
});
const result = await refreshClineRecommendedModels();
expect(result.clinePass.map((model) => model.id)).to.deep.equal(["cline-pass/zai/glm-5.2"]);
});
});
@@ -21,6 +21,7 @@ import {
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet51mModelId,
} from "@/shared/api"
import { getAxiosSettings } from "@/shared/net"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
@@ -87,6 +88,37 @@ interface ClineRawModelInfo {
// Track pending refresh promise to prevent duplicate concurrent fetches
let pendingRefresh: Promise<Record<string, ModelInfo>> | null = null
interface ModelIdAliasRule {
canonicalPrefix: string
aliasPrefix: string
}
// Mirrors @cline/llms VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES.
const VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES = [
{ canonicalPrefix: "zai/", aliasPrefix: "z-ai/" },
] as const satisfies readonly ModelIdAliasRule[]
function preferCanonicalModelIds<T>(models: Record<string, T>, rules: readonly ModelIdAliasRule[]): Record<string, T> {
return Object.fromEntries(
Object.entries(models).filter(([modelId]) => {
for (const rule of rules) {
if (!modelId.startsWith(rule.aliasPrefix)) {
continue
}
const canonicalModelId = `${rule.canonicalPrefix}${modelId.slice(rule.aliasPrefix.length)}`
if (canonicalModelId in models) {
return false
}
}
return true
}),
)
}
function preferClineCanonicalModelIds(models: Record<string, ModelInfo>): Record<string, ModelInfo> {
return preferCanonicalModelIds(models, VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES)
}
async function fetchRawClineModels(): Promise<ClineRawModelInfo[]> {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/models`, getAxiosSettings())
@@ -107,7 +139,7 @@ async function fetchRawClineModels(): Promise<ClineRawModelInfo[]> {
export async function refreshClineModels(controller: Controller): Promise<Record<string, ModelInfo>> {
const shouldUseClineEndpointSource = featureFlagsService.getBooleanFlagEnabled(FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT)
if (!shouldUseClineEndpointSource) {
return refreshOpenRouterModels(controller)
return preferClineCanonicalModelIds(await refreshOpenRouterModels(controller))
}
// Check in-memory cache first
@@ -181,6 +213,15 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
// Apply model-specific overrides for known models
switch (rawModel.id) {
case "anthropic/claude-sonnet-5":
case "anthropic/claude-5-sonnet":
modelInfo.contextWindow = 200_000
modelInfo.supportsPromptCache = true
modelInfo.inputPrice = 2.0
modelInfo.outputPrice = 10.0
modelInfo.cacheWritesPrice = 2.5
modelInfo.cacheReadsPrice = 0.2
break
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
@@ -273,12 +314,17 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
rawModel.id === "anthropic/claude-sonnet-4" ||
rawModel.id === "anthropic/claude-sonnet-4.5" ||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
rawModel.id === "anthropic/claude-4.6-sonnet"
rawModel.id === "anthropic/claude-4.6-sonnet" ||
rawModel.id === "anthropic/claude-sonnet-5" ||
rawModel.id === "anthropic/claude-5-sonnet"
) {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
if (rawModel.id === "anthropic/claude-sonnet-5" || rawModel.id === "anthropic/claude-5-sonnet") {
models[openRouterClaudeSonnet51mModelId] = claudeSonnet1mModelInfo
}
if (rawModel.id === "anthropic/claude-sonnet-4") {
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
}
@@ -319,8 +365,9 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
if (Object.keys(models).length === 0) {
throw new Error("No Cline models returned from API")
}
// Save models and cache them in memory
await fs.writeFile(clineModelsFilePath, JSON.stringify(models))
await fs.writeFile(clineModelsFilePath, JSON.stringify(preferClineCanonicalModelIds(models)))
Logger.log("Cline models fetched and saved")
} catch (error) {
Logger.error("Error fetching Cline models:", error)
@@ -340,6 +387,7 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
// Avoid poisoning in-memory cache with an empty model map after transient failures.
if (Object.keys(models).length > 0) {
models = preferClineCanonicalModelIds(models)
StateManager.get().setModelsCache("cline", models)
}
@@ -23,6 +23,31 @@ export interface ClineRecommendedModelsData {
}
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000;
const CLINE_PASS_MODEL_ID_ALIAS_RULES = [
{ canonicalPrefix: "cline-pass/zai/", aliasPrefix: "cline-pass/z-ai/" },
] as const;
function normalizeClineProviderRecommendedModelId(modelId: string): string {
const zaiPrefix = "zai/";
return modelId.startsWith(zaiPrefix) ? `z-ai/${modelId.slice(zaiPrefix.length)}` : modelId;
}
function normalizeClineProviderRecommendedModels(
models: ClineRecommendedModelData[],
): ClineRecommendedModelData[] {
return models.map((model) => {
const id = normalizeClineProviderRecommendedModelId(model.id);
if (id === model.id) {
return model;
}
return {
...model,
id,
name: model.name === model.id ? id : model.name,
};
});
}
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null;
let inMemoryCache: {
@@ -30,6 +55,26 @@ let inMemoryCache: {
timestamp: number;
} | null = null;
function preferCanonicalRecommendedModels(
models: ClineRecommendedModelData[],
): ClineRecommendedModelData[] {
const modelIds = new Set(models.map((model) => model.id));
return models.filter((model) => {
for (const rule of CLINE_PASS_MODEL_ID_ALIAS_RULES) {
if (!model.id.startsWith(rule.aliasPrefix)) {
continue;
}
const canonicalModelId = `${rule.canonicalPrefix}${model.id.slice(rule.aliasPrefix.length)}`;
if (modelIds.has(canonicalModelId)) {
return false;
}
}
return true;
});
}
function normalizeRecommendedModel(
raw: unknown,
): ClineRecommendedModelData | null {
@@ -89,7 +134,11 @@ function normalizeRecommendedModelsResponse(
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null);
return { recommended, free, clinePass };
return {
recommended: normalizeClineProviderRecommendedModels(recommended),
free: normalizeClineProviderRecommendedModels(free),
clinePass: preferCanonicalRecommendedModels(clinePass),
};
}
export async function refreshClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
@@ -161,14 +210,9 @@ async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedMo
"utf8",
);
const parsed = JSON.parse(fileContents);
if (parsed) {
result = {
recommended: Array.isArray(parsed.recommended)
? parsed.recommended
: [],
free: Array.isArray(parsed.free) ? parsed.free : [],
clinePass: Array.isArray(parsed.clinePass) ? parsed.clinePass : [],
};
const normalized = normalizeRecommendedModelsResponse(parsed);
if (normalized) {
result = normalized;
Logger.log("Loaded Cline recommended models from cache");
}
}
@@ -18,6 +18,7 @@ import {
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet51mModelId,
} from "@/shared/api"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
@@ -150,6 +151,16 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
}
switch (rawModel.id) {
case "anthropic/claude-sonnet-5":
case "anthropic/claude-5-sonnet":
// NOTE: we artificially restrict the context window to 200k to keep costs low for users, and have a :1m model variant created below for users that want to use the full 1m.
modelInfo.contextWindow = 200_000
modelInfo.supportsPromptCache = true
modelInfo.inputPrice = 2.0
modelInfo.outputPrice = 10.0
modelInfo.cacheWritesPrice = 2.5
modelInfo.cacheReadsPrice = 0.2
break
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
@@ -294,11 +305,17 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
rawModel.id === "anthropic/claude-sonnet-4.5" ||
rawModel.id === "anthropic/claude-4.5-sonnet" ||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
rawModel.id === "anthropic/claude-4.6-sonnet"
rawModel.id === "anthropic/claude-4.6-sonnet" ||
rawModel.id === "anthropic/claude-sonnet-5" ||
rawModel.id === "anthropic/claude-5-sonnet"
) {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
// sonnet 5
if (rawModel.id === "anthropic/claude-sonnet-5" || rawModel.id === "anthropic/claude-5-sonnet") {
models[openRouterClaudeSonnet51mModelId] = claudeSonnet1mModelInfo
}
// sonnet 4
if (rawModel.id === "anthropic/claude-sonnet-4") {
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
@@ -19,19 +19,16 @@ describe("Hook System", () => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async () => {
beforeEach(async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_TEST_TIMEOUT_MS)
}
setDistinctId("test-id")
hookTestEnv = await createHookTestEnv()
tempDir = hookTestEnv.tempDir
sandbox = hookTestEnv.sandbox
})
beforeEach(function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_TEST_TIMEOUT_MS)
}
})
afterEach(async () => {
await hookTestEnv.cleanup()
})
@@ -6,7 +6,7 @@ import { PromptBuilder } from "../registry/PromptBuilder"
import { SystemPromptSection } from "../templates/placeholders"
import type { ComponentRegistry, PromptVariant, SystemPromptContext } from "../types"
import { createVariant } from "../variants/variant-builder"
import { mockProviderInfo } from "./integration.test"
import { mockProviderInfo } from "./test-fixtures"
describe("PromptBuilder", () => {
const mockContext: SystemPromptContext = {
@@ -3,7 +3,7 @@ import type { McpHub } from "@/services/mcp/McpHub"
import { ModelFamily } from "@/shared/prompts"
import { PromptRegistry } from "../registry/PromptRegistry"
import type { SystemPromptContext } from "../types"
import { mockProviderInfo } from "./integration.test"
import { mockProviderInfo } from "./test-fixtures"
describe("PromptRegistry", () => {
let registry: PromptRegistry
@@ -2,7 +2,7 @@ import { expect } from "chai"
import type { McpHub } from "@/services/mcp/McpHub"
import { TemplateEngine } from "../templates/TemplateEngine"
import type { SystemPromptContext } from "../types"
import { mockProviderInfo } from "./integration.test"
import { mockProviderInfo } from "./test-fixtures"
describe("TemplateEngine", () => {
let templateEngine: TemplateEngine
@@ -25,6 +25,7 @@ import type { McpHub } from "@/services/mcp/McpHub"
import { ModelFamily } from "@/shared/prompts"
import { getSystemPrompt } from "../index"
import type { SystemPromptContext } from "../types"
import { mockProviderInfo } from "./test-fixtures"
// ============================================================================
// Configuration
@@ -111,12 +112,6 @@ async function assertSnapshot(name: string, content: string): Promise<void> {
// Test Context Helpers
// ============================================================================
export const mockProviderInfo = {
providerId: "test",
model: { id: "fast", info: { supportsPromptCache: false } },
mode: "act" as const,
}
const makeProviderInfo = (modelId: string, providerId = "test") => ({
providerId: modelId.includes("ollama") ? "ollama" : providerId,
model: { ...mockProviderInfo.model, id: modelId },
@@ -6,7 +6,7 @@ import { ClineToolSet } from "../registry/ClineToolSet"
import { PromptRegistry } from "../registry/PromptRegistry"
import { new_task_variants } from "../tools/new_task"
import type { SystemPromptContext } from "../types"
import { mockProviderInfo } from "./integration.test"
import { mockProviderInfo } from "./test-fixtures"
const baseContext: SystemPromptContext = {
cwd: "/test/project",
@@ -0,0 +1,8 @@
// Shared provider info fixture used across the system-prompt test suite.
// Kept in a non-`.test.ts` file (like matcher-test.ts) so it can be exported
// and imported by sibling test files without tripping biome's noExportsInTest rule.
export const mockProviderInfo = {
providerId: "test",
model: { id: "fast", info: { supportsPromptCache: false } },
mode: "act" as const,
}
+10 -8
View File
@@ -87,7 +87,6 @@ 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 {
@@ -2039,11 +2038,7 @@ export class Task {
? apiConfig.planModeApiProvider
: apiConfig.actModeApiProvider
) as string;
const providerId =
configuredProviderId === "cline-pass" &&
!featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS)
? "cline"
: configuredProviderId;
const providerId = configuredProviderId;
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt");
return { model, providerId, customPrompt, mode };
}
@@ -2472,6 +2467,11 @@ export class Task {
const isOrgClinePassRestrictionError = clineError.isErrorType(
ClineErrorType.OrgClinePassRestriction,
);
// ClinePass period limits reset in hours/days — auto-retrying is
// pointless and only delays the actionable error UI.
const isClinePassLimitError = clineError.isErrorType(
ClineErrorType.ClinePassLimit,
);
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
const isClineProviderInsufficientCredits = (() => {
@@ -2499,6 +2499,7 @@ export class Task {
!quotaExceeded &&
!isEntitlementError &&
!isOrgClinePassRestrictionError &&
!isClinePassLimitError &&
this.taskState.autoRetryAttempts < 3;
if (shouldRetry) {
// Auto-retry enabled with max 3 attempts: automatically approve the retry
@@ -2562,7 +2563,8 @@ export class Task {
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError &&
!isOrgClinePassRestrictionError;
!isOrgClinePassRestrictionError &&
!isClinePassLimitError;
if (showRetry) {
await this.say(
"error_retry",
@@ -2849,7 +2851,7 @@ export class Task {
"mistake_limit_reached",
this.api.getModel().id.includes("claude")
? `This may indicate a failure in Cline's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4.5 Sonnet for its advanced agentic coding capabilities.",
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use a stronger model with better tool-calling and agentic coding capabilities.",
);
if (response === "messageResponse") {
// Display the user's message in the chat UI
@@ -10,6 +10,7 @@ export enum ClineErrorType {
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
OrgClinePassRestriction = "orgClinePassRestriction",
ClinePassLimit = "clinePassLimit",
}
interface ErrorDetails {
@@ -57,6 +58,46 @@ const ORG_CLINE_PASS_RESTRICTION_MESSAGE =
const ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE =
"organization accounts cannot use clinepass subscriptions";
// The ClinePass period limit message is dynamic ("weekly"/"5-hour" period, "7d"/"12h"
// reset), so it is matched by its fixed prefix/suffix with the ClinePass marker in
// between. Plain indexOf scanning — no regex, so no backtracking on hostile input.
const CLINE_PASS_LIMIT_PREFIX = "you have reached your";
const CLINE_PASS_LIMIT_MARKER = "clinepass limit";
const CLINE_PASS_LIMIT_SUFFIX = "please try again later.";
function findClinePassLimitMessageBounds(
text: string,
): { start: number; end: number } | undefined {
const normalized = text.toLowerCase();
const start = normalized.indexOf(CLINE_PASS_LIMIT_PREFIX);
if (start === -1) {
return undefined;
}
const suffixStart = normalized.indexOf(CLINE_PASS_LIMIT_SUFFIX, start);
if (suffixStart === -1) {
return undefined;
}
const end = suffixStart + CLINE_PASS_LIMIT_SUFFIX.length;
if (!normalized.slice(start, end).includes(CLINE_PASS_LIMIT_MARKER)) {
return undefined;
}
return { start, end };
}
export function isClinePassLimitMessage(text: string): boolean {
return findClinePassLimitMessageBounds(text) !== undefined;
}
export function extractClinePassLimitMessage(
text: string,
): string | undefined {
const bounds = findClinePassLimitMessageBounds(text);
return bounds ? text.slice(bounds.start, bounds.end) : undefined;
}
export class ClineError extends Error {
readonly title = "ClineError";
readonly _error: ErrorDetails;
@@ -202,6 +243,18 @@ export class ClineError extends Error {
return ClineErrorType.Entitlement;
}
// ClinePass period limits (weekly/5-hour) are user-actionable (switch to
// usage-based billing) and must not fall through to the generic 403 auth
// handling below or the 429 rate-limit patterns.
const detailMessage =
typeof details?.message === "string" ? details.message : undefined;
if (
isClinePassLimitMessage(detailMessage ?? "") ||
isClinePassLimitMessage(message ?? "")
) {
return ClineErrorType.ClinePassLimit;
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429;
if (
@@ -1,6 +1,11 @@
import { describe, it } from "mocha";
import "should";
import { ClineError, ClineErrorType } from "../ClineError";
import {
ClineError,
ClineErrorType,
extractClinePassLimitMessage,
isClinePassLimitMessage,
} from "../ClineError";
describe("ClineError", () => {
describe("getErrorType", () => {
@@ -59,7 +64,7 @@ describe("ClineError", () => {
"Error 403: the user is not subscribed to required model plan",
},
},
"cline-pass/glm-5.1",
"cline-pass/glm-5.2",
"cline-pass",
);
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
@@ -89,5 +94,74 @@ describe("ClineError", () => {
const result = ClineError.getErrorType(err);
(result !== ClineErrorType.OrgClinePassRestriction).should.be.true();
});
it("should classify ClinePass period limit messages as ClinePassLimit", () => {
const err = new ClineError(
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
);
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
});
it("should classify nested ClinePass period limit messages as ClinePassLimit", () => {
// ClineError maps `error.error` into `details`, matching the real provider error shape.
const err = new ClineError({
message: "403 Error 403",
error: {
message:
"You have reached your monthly ClinePass limit. The limit resets in 12h, please try again later.",
},
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
});
it("should prefer ClinePassLimit over Auth for a 403 with the limit message", () => {
// status 403 falls inside the generic auth-status range; the limit message must win.
const err = new ClineError({
message:
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
status: 403,
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
});
});
describe("isClinePassLimitMessage", () => {
it("matches limit messages with variable period and reset", () => {
isClinePassLimitMessage(
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
).should.be.true();
isClinePassLimitMessage(
"You have reached your 5-hour ClinePass limit. The limit resets in 5h, please try again later.",
).should.be.true();
});
it("does not match unrelated or partial messages", () => {
isClinePassLimitMessage(
"the user is not subscribed to required model plan",
).should.be.false();
isClinePassLimitMessage(
`You have reached your\t-\tClinepass limit.The limit resets in\t${"\t".repeat(10_000)}`,
).should.be.false();
});
});
describe("extractClinePassLimitMessage", () => {
it("extracts the limit message out of a wrapped error string", () => {
const message =
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
extractClinePassLimitMessage(`429 Error: ${message}`)!.should.equal(
message,
);
});
it("returns undefined when there is no limit message", () => {
(
extractClinePassLimitMessage("some other error") === undefined
).should.be.true();
});
});
});
@@ -0,0 +1,82 @@
import { expect } from "chai"
import {
buildModelInfoNameMap,
clinePassModelInfoSaneDefaults,
internationalZAiModels,
mainlandZAiModels,
type ModelInfo,
resolveClinePassModelInfo,
} from "../api"
describe("ClinePass model info", () => {
const createModelInfo = (name: string, contextWindow: number): ModelInfo => ({
name,
contextWindow,
maxTokens: 8_192,
supportsPromptCache: false,
supportsReasoning: false,
thinkingConfig: { maxBudget: 16_384 },
})
it("prefers dynamic model metadata for ClinePass GLM aliases", () => {
const modelInfo = resolveClinePassModelInfo(
"cline-pass/z-ai/glm-5.2",
buildModelInfoNameMap({
"z-ai/glm-5.2": createModelInfo("OpenRouter GLM 5.2", 1_000_000),
}),
)
expect(modelInfo.name).to.equal("OpenRouter GLM 5.2")
expect(modelInfo.contextWindow).to.equal(1_000_000)
expect(modelInfo.thinkingConfig).to.deep.equal({ maxBudget: 16_384 })
})
it("falls back to static ClinePass metadata when dynamic metadata is unavailable", () => {
const modelInfo = resolveClinePassModelInfo("cline-pass/glm-5.2")
expect(modelInfo.contextWindow).to.equal(202_752)
expect(modelInfo.supportsReasoning).to.equal(true)
expect(modelInfo.thinkingConfig).to.equal(undefined)
})
it("preserves dynamic ClinePass model info when no static alias exists", () => {
const modelInfo = resolveClinePassModelInfo(
"cline-pass/new-model",
buildModelInfoNameMap({
"zai/new-model": createModelInfo("New model", 1_000_000),
}),
)
expect(modelInfo.name).to.equal("New model")
expect(modelInfo.supportsReasoning).to.equal(false)
expect(modelInfo.thinkingConfig).to.deep.equal({ maxBudget: 16_384 })
})
it("returns stored info for a free (non cline-pass prefixed) model id", () => {
const freeModelInfo = createModelInfo("Trinity Large Preview", 512_000)
const modelInfo = resolveClinePassModelInfo(
"arcee-ai/trinity-large-preview:free",
buildModelInfoNameMap({ "arcee-ai/trinity-large-preview:free": freeModelInfo }),
)
expect(modelInfo).to.deep.equal(freeModelInfo)
})
it("falls back to sane defaults for a free model id without dynamic metadata", () => {
const modelInfo = resolveClinePassModelInfo("kwaipilot/kat-coder-pro")
expect(modelInfo).to.deep.equal(clinePassModelInfoSaneDefaults)
})
})
describe("Z AI model info", () => {
it("includes GLM 5.2 for both direct Z AI entrypoints", () => {
for (const models of [internationalZAiModels, mainlandZAiModels]) {
expect(models["glm-5.2"].contextWindow).to.equal(1_000_000)
expect(models["glm-5.2"].maxTokens).to.equal(128_000)
expect(models["glm-5.2"].inputPrice).to.equal(1.4)
expect(models["glm-5.2"].outputPrice).to.equal(4.4)
expect(models["glm-5.2"].cacheReadsPrice).to.equal(0.26)
}
})
})
+141 -31
View File
@@ -179,10 +179,33 @@ export const hicapModelInfoSaneDefaults: HicapCompatibleModelInfo = {
// Anthropic
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
export type AnthropicModelId = keyof typeof anthropicModels
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-5-20250929"
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-5"
export const ANTHROPIC_MIN_THINKING_BUDGET = 1_024
export const ANTHROPIC_MAX_THINKING_BUDGET = 6_000
export const anthropicModels = {
"claude-sonnet-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
},
"claude-sonnet-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-4-6": {
maxTokens: 64_000,
contextWindow: 200_000,
@@ -471,15 +494,15 @@ export const anthropicModels = {
// Claude Code
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5-20250929"
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-5"
export const claudeCodeModels = {
sonnet: {
...anthropicModels["claude-sonnet-4-5-20250929"],
...anthropicModels["claude-sonnet-5"],
supportsImages: false,
supportsPromptCache: false,
},
"sonnet[1m]": {
...anthropicModels["claude-sonnet-4-5-20250929:1m"],
...anthropicModels["claude-sonnet-5:1m"],
supportsImages: false,
supportsPromptCache: false,
},
@@ -499,6 +522,16 @@ export const claudeCodeModels = {
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-5": {
...anthropicModels["claude-sonnet-5"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-5[1m]": {
...anthropicModels["claude-sonnet-5:1m"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-4-6": {
...anthropicModels["claude-sonnet-4-6"],
supportsImages: false,
@@ -597,8 +630,33 @@ export const claudeCodeModels = {
// AWS Bedrock
// https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
export type BedrockModelId = keyof typeof bedrockModels
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-sonnet-4-5-20250929-v1:0"
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-sonnet-5"
export const bedrockModels = {
"anthropic.claude-sonnet-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
},
"anthropic.claude-sonnet-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"anthropic.claude-sonnet-4-6": {
maxTokens: 64_000,
contextWindow: 200_000,
@@ -991,25 +1049,26 @@ export const bedrockModels = {
// OpenRouter
// https://openrouter.ai/models?order=newest&supported_parameters=tools
export const openRouterDefaultModelId = "anthropic/claude-sonnet-4.5" // will always exist in openRouterModels
export const openRouterDefaultModelId = "anthropic/claude-sonnet-5" // will always exist in openRouterModels
export const openRouterClaudeSonnet41mModelId = `anthropic/claude-sonnet-4${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeSonnet451mModelId = `anthropic/claude-sonnet-4.5${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeSonnet461mModelId = `anthropic/claude-sonnet-4.6${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeSonnet51mModelId = `anthropic/claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus461mModelId = `anthropic/claude-opus-4.6${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus471mModelId = `anthropic/claude-opus-4.7${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus481mModelId = `anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeFable51mModelId = `anthropic/claude-fable-5${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterDefaultModelInfo: ModelInfo = {
maxTokens: 64_000,
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
description:
"Claude Sonnet 4.5 delivers superior intelligence across coding, agentic search, and AI agent capabilities. It's a powerful choice for agentic coding, and can complete tasks across the entire software development lifecycle, from initial planning to bug fixes, maintenance to large refactors. It offers strong performance in both planning and solving for complex coding tasks, making it an ideal choice to power end-to-end software development processes.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
"Claude Sonnet 5 is Anthropic's latest Sonnet model for coding, agents, and professional work. It supports adaptive thinking, prompt caching, image inputs, and long-context workflows.",
}
// Cline custom model - Devstral
@@ -1025,7 +1084,7 @@ export const clineDevstralModelInfo: ModelInfo = {
}
export type ClinePassModelId = keyof typeof clinePassModels
export const clinePassDefaultModelId = "cline-pass/glm-5.1"
export const clinePassDefaultModelId = "cline-pass/glm-5.2"
export const clinePassModelInfoSaneDefaults: ModelInfo = {
maxTokens: 8_192,
contextWindow: 128_000,
@@ -1039,8 +1098,8 @@ export const clinePassModelInfoSaneDefaults: ModelInfo = {
description: "",
}
export const clinePassModels = {
"cline-pass/glm-5.1": {
name: "cline-pass/glm-5.1",
"cline-pass/glm-5.2": {
name: "cline-pass/glm-5.2",
maxTokens: 131_072,
contextWindow: 202_752,
supportsImages: false,
@@ -1069,9 +1128,12 @@ export function buildModelInfoNameMap(models: Record<string, ModelInfo>): Record
}
export function resolveClinePassModelInfo(modelId: string, modelInfoByName?: Record<string, ModelInfo>): ModelInfo {
const modelSlug = getModelSlug(modelId)
const clinePassSlugModelId = `cline-pass/${modelSlug}`
return (
modelInfoByName?.[modelSlug] ??
clinePassModels[modelId as keyof typeof clinePassModels] ??
modelInfoByName?.[getModelSlug(modelId)] ??
clinePassModels[clinePassSlugModelId as keyof typeof clinePassModels] ??
clinePassModelInfoSaneDefaults
)
}
@@ -1230,6 +1292,29 @@ export const vertexModels = {
supportsThinkingLevel: true,
},
},
"claude-sonnet-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
supportsReasoning: true,
},
"claude-sonnet-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
supportsReasoning: true,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-4-6": {
maxTokens: 64_000,
contextWindow: 200_000,
@@ -4360,18 +4445,18 @@ export const groqModels = {
// Requesty
// https://requesty.ai/models
export const requestyDefaultModelId = "anthropic/claude-3-7-sonnet-latest"
export const requestyDefaultModelId = "anthropic/claude-sonnet-5"
export const requestyDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description: "Anthropic's most intelligent model. Highest level of intelligence and capability.",
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
description: "Anthropic's latest Sonnet model for coding, agents, and professional work.",
}
// SAP AI Core
@@ -4380,6 +4465,13 @@ export const sapAiCoreDefaultModelId: SapAiCoreModelId = "anthropic--claude-3.5-
// Pricing is calculated using Capacity Units, not directly in USD
const sapAiCoreModelDescription = "Pricing is calculated using SAP's Capacity Units rather than direct USD pricing."
export const sapAiCoreModels = {
"anthropic--claude-sonnet-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"anthropic--claude-4.5-haiku": {
maxTokens: 64000,
contextWindow: 200_000,
@@ -4983,12 +5075,22 @@ export type BasetenModelId = keyof typeof basetenModels
export const basetenDefaultModelId = "zai-org/GLM-4.6" satisfies BasetenModelId
// Z AI
// https://docs.z.ai/guides/llm/glm-5.2
// https://docs.z.ai/guides/llm/glm-5.1
// https://docs.z.ai/guides/llm/glm-5
// https://docs.z.ai/guides/overview/pricing
export type internationalZAiModelId = keyof typeof internationalZAiModels
export const internationalZAiDefaultModelId: internationalZAiModelId = "glm-5.1"
export const internationalZAiModels = {
"glm-5.2": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: true,
cacheReadsPrice: 0.26,
inputPrice: 1.4,
outputPrice: 4.4,
},
"glm-5.1": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -5054,6 +5156,15 @@ export const internationalZAiModels = {
export type mainlandZAiModelId = keyof typeof mainlandZAiModels
export const mainlandZAiDefaultModelId: mainlandZAiModelId = "glm-5.1"
export const mainlandZAiModels = {
"glm-5.2": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: true,
cacheReadsPrice: 0.26,
inputPrice: 1.4,
outputPrice: 4.4,
},
"glm-5.1": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -5193,8 +5304,7 @@ export const fireworksModels = {
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.3,
description:
"Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
description: "Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p6-fast": {
maxTokens: 262000,
@@ -5205,8 +5315,7 @@ export const fireworksModels = {
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.3,
description:
"Kimi K2.6 Fast router for high-performance agentic workloads with vision and text reasoning.",
description: "Kimi K2.6 Fast router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p7-code-fast": {
maxTokens: 262000,
@@ -5217,8 +5326,7 @@ export const fireworksModels = {
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.38,
description:
"Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
description: "Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
},
"accounts/fireworks/models/deepseek-v4-flash": {
maxTokens: 384000,
@@ -5253,7 +5361,8 @@ export const fireworksModels = {
outputPrice: 4.4,
cacheWritesPrice: 0,
cacheReadsPrice: 0.26,
description: "GLM 5.2 is a next-generation general-purpose model optimized for coding, reasoning, and agentic workflows with a 1M context window.",
description:
"GLM 5.2 is a next-generation general-purpose model optimized for coding, reasoning, and agentic workflows with a 1M context window.",
},
"accounts/fireworks/models/glm-5p1": {
maxTokens: 131072,
@@ -5297,7 +5406,8 @@ export const fireworksModels = {
outputPrice: 1.2,
cacheWritesPrice: 0,
cacheReadsPrice: 0.06,
description: "MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
description:
"MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
},
"accounts/fireworks/models/qwen3p7-plus": {
maxTokens: 262144,
+4 -4
View File
@@ -55,8 +55,8 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
},
{
group: "frontier",
id: "anthropic/claude-sonnet-4.5",
name: "Anthropic: Claude Sonnet 4.5",
id: "anthropic/claude-sonnet-5",
name: "Anthropic: Claude Sonnet 5",
badge: "Best",
score: 97,
latency: 3,
@@ -64,8 +64,8 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
inputPrice: 2.0,
outputPrice: 10.0,
tiers: [],
},
},
@@ -22,8 +22,8 @@ export const CLINE_RECOMMENDED_MODELS_FALLBACK: ClineRecommendedModelsData = {
tags: ["NEW"],
},
{
id: "anthropic/claude-sonnet-4.6",
name: "Anthropic Claude Sonnet 4.6",
id: "anthropic/claude-sonnet-5",
name: "Anthropic Claude Sonnet 5",
description: "Latest Sonnet release with strong coding and agent performance",
tags: ["NEW"],
},
@@ -1,11 +1,13 @@
import { normalizeOpenaiReasoningEffort, type OpenaiReasoningEffort } from "../storage/types"
export interface ClaudeOpusAdaptiveThinkingSettings {
export interface ClaudeAdaptiveThinkingSettings {
enabled: boolean
effort?: OpenaiReasoningEffort
}
export function isClaudeOpusAdaptiveThinkingModel(modelId?: string): boolean {
export type ClaudeOpusAdaptiveThinkingSettings = ClaudeAdaptiveThinkingSettings
export function isClaudeAdaptiveThinkingModel(modelId?: string): boolean {
if (!modelId) {
return false
}
@@ -14,14 +16,18 @@ export function isClaudeOpusAdaptiveThinkingModel(modelId?: string): boolean {
const adaptiveVersions = ["4-6", "4.6", "4-7", "4.7", "4-8", "4.8"]
return (
id.includes("claude-fable-5") ||
id.includes("claude-sonnet-5") ||
id.includes("claude-5-sonnet") ||
adaptiveVersions.some((version) => id.includes(`claude-opus-${version}`) || id.includes(`claude-${version}-opus`))
)
}
export function resolveClaudeOpusAdaptiveThinking(
export const isClaudeOpusAdaptiveThinkingModel = isClaudeAdaptiveThinkingModel
export function resolveClaudeAdaptiveThinking(
reasoningEffort?: string,
legacyThinkingBudgetTokens?: number,
): ClaudeOpusAdaptiveThinkingSettings {
): ClaudeAdaptiveThinkingSettings {
if (reasoningEffort) {
const effort = normalizeOpenaiReasoningEffort(reasoningEffort)
return effort === "none" ? { enabled: false } : { enabled: true, effort }
@@ -30,6 +36,8 @@ export function resolveClaudeOpusAdaptiveThinking(
return legacyThinkingBudgetTokens && legacyThinkingBudgetTokens > 0 ? { enabled: true, effort: "high" } : { enabled: false }
}
export const resolveClaudeOpusAdaptiveThinking = resolveClaudeAdaptiveThinking
export function supportsReasoningEffortForModel(modelId?: string): boolean {
if (!modelId) {
return false
@@ -37,11 +45,20 @@ export function supportsReasoningEffortForModel(modelId?: string): boolean {
const id = modelId.toLowerCase()
return (
id.includes("deepseek") ||
id.includes("gemini") ||
id.includes("glm") ||
id.includes("gpt") ||
id.includes("kimi") ||
id.includes("mimo") ||
id.includes("minimax") ||
id.includes("moonshot") ||
id.startsWith("openai/o") ||
id.includes("/o") ||
id.startsWith("o") ||
id.includes("qwen") ||
id.includes("z-ai") ||
id.includes("zai") ||
id.includes("grok")
)
}
@@ -94,8 +94,8 @@ export const E2E_MOCK_CLINE_RECOMMENDED_MODELS = {
],
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
id: "anthropic/claude-sonnet-5",
name: "anthropic/claude-sonnet-5",
description: "Recommended model for e2e onboarding",
tags: ["BEST"],
},
@@ -123,12 +123,12 @@ export const E2E_MOCK_CLINE_MODELS = [
supported_parameters: [],
},
{
id: "anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
id: "anthropic/claude-sonnet-5",
name: "anthropic/claude-sonnet-5",
description: "Recommended model for e2e onboarding",
context_length: 200_000,
top_provider: {
max_completion_tokens: 64_000,
max_completion_tokens: 128_000,
context_length: 200_000,
is_moderated: false,
},
@@ -136,10 +136,10 @@ export const E2E_MOCK_CLINE_MODELS = [
modality: "text->text",
},
pricing: {
prompt: "0.000003",
completion: "0.000015",
input_cache_read: "0.0000003",
input_cache_write: "0.00000375",
prompt: "0.000002",
completion: "0.00001",
input_cache_read: "0.0000002",
input_cache_write: "0.0000025",
},
supported_parameters: ["include_reasoning"],
},
+3 -1
View File
@@ -20,7 +20,9 @@ describe("Cline Extension", () => {
})
it("should successfully execute the plus button command", async () => {
await new Promise((resolve) => setTimeout(resolve, 400))
const packageJSON = JSON.parse(await readFile(packagePath, "utf8"))
const id = packageJSON.publisher + "." + packageJSON.name
await vscode.extensions.getExtension(id)?.activate()
await vscode.commands.executeCommand("cline.plusButtonClicked")
})
@@ -1,6 +1,7 @@
import { describe, it } from "mocha"
import "should"
import type { ApiHandlerModel, ApiProviderInfo } from "@core/api"
import { isClaudeAdaptiveThinkingModel } from "@shared/utils/reasoning-support"
import {
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
isClaude4PlusModelFamily,
@@ -13,6 +14,7 @@ import {
isPoolsideModelFamily,
modelDoesntSupportWebp,
shouldSkipReasoningForModel,
supportsReasoningEffortForModel,
} from "../model-utils"
// Minimal helper — modelDoesntSupportWebp only reads apiHandlerModel.id
@@ -37,6 +39,7 @@ describe("shouldSkipReasoningForModel", () => {
shouldSkipReasoningForModel("claude-3-sonnet").should.equal(false)
shouldSkipReasoningForModel("gpt-4").should.equal(false)
shouldSkipReasoningForModel("gemini-pro").should.equal(false)
shouldSkipReasoningForModel("zai/glm-5.2").should.equal(false)
})
it("should return false for undefined or empty model IDs", () => {
@@ -50,6 +53,31 @@ describe("shouldSkipReasoningForModel", () => {
})
})
describe("supportsReasoningEffortForModel", () => {
it("should return true for OpenRouter/Cline model families that use reasoning effort", () => {
for (const modelId of [
"zai/glm-5.2",
"z-ai/glm-5.2",
"cline-pass/glm-5.2",
"moonshotai/kimi-k2-thinking",
"kimi-k2-thinking",
"accounts/fireworks/models/kimi-k2p6",
"accounts/fireworks/models/minimax-m3",
"minimax/MiniMax-M2.7",
"provider/mimo-vl",
"qwen/qwen3.7-max",
"deepseek/deepseek-r1",
]) {
supportsReasoningEffortForModel(modelId).should.equal(true)
}
})
it("should return false for undefined and unrelated model IDs", () => {
supportsReasoningEffortForModel(undefined).should.equal(false)
supportsReasoningEffortForModel("anthropic/claude-sonnet-4.5").should.equal(false)
})
})
describe("isClaude4PlusModelFamily", () => {
it("should return true for Claude 4+ model IDs with version numbers", () => {
isClaude4PlusModelFamily("claude-sonnet-4-5-20250929").should.equal(true)
@@ -77,6 +105,18 @@ describe("isClaude4PlusModelFamily", () => {
})
})
describe("isClaudeAdaptiveThinkingModel", () => {
it("should return true for Claude Sonnet 5 IDs across provider naming variants", () => {
for (const modelId of [
"claude-sonnet-5",
"anthropic/claude-sonnet-5:1m",
"anthropic/claude-5-sonnet",
]) {
isClaudeAdaptiveThinkingModel(modelId).should.equal(true)
}
})
})
describe("isGPT5ModelFamily", () => {
it("should return true for GPT-5 model IDs with hyphen", () => {
isGPT5ModelFamily("gpt-5").should.equal(true)
+1 -1
View File
@@ -46,7 +46,7 @@ export function shouldSkipReasoningForModel(modelId?: string): boolean {
if (!modelId) {
return false
}
return modelId.includes("grok-4") || modelId.includes("devstral") || modelId.includes("glm")
return modelId.includes("grok-4") || modelId.includes("devstral")
}
export function isAnthropicModelId(modelId: string): modelId is AnthropicModelId {
@@ -1106,6 +1106,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
switch (selectedProvider) {
case "cline":
return `${selectedProvider}:${selectedModelId}`
case "cline-pass":
// Free models selected on ClinePass go through Cline usage billing,
// so label them the same way as the cline provider
return selectedModelId.startsWith("cline-pass/")
? `${selectedProvider}:${selectedModelId.replace(/^cline-pass\//, "")}`
: `cline:${selectedModelId}`
case "openai":
return `openai-compat:${selectedModelId}`
case "vscode-lm":
@@ -0,0 +1,69 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import { useState } from "react";
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers";
interface ClinePassLimitErrorProps {
message: string;
}
const ClinePassLimitError = ({ message }: ClinePassLimitErrorProps) => {
const { handleFieldsChange } = useApiConfigurationHandlers();
const [isSwitching, setIsSwitching] = useState(false);
const [didSwitch, setDidSwitch] = useState(false);
const [error, setError] = useState<string | undefined>();
const handleSwitchToUsageBasedBilling = async () => {
setIsSwitching(true);
setError(undefined);
try {
await handleFieldsChange({
planModeApiProvider: "cline",
actModeApiProvider: "cline",
});
setDidSwitch(true);
} catch (error) {
console.error("Failed to switch to Cline usage-based billing:", error);
setError(
"Failed to switch provider. Select Cline Usage-Billing in API Configuration settings.",
);
} finally {
setIsSwitching(false);
}
};
return (
<div
className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)"
data-testid="cline-pass-limit-error"
>
<div className="text-error mb-2">ClinePass limit reached</div>
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
{message}
</div>
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
Would you like to switch to Usage-Based billing and retry with the
Cline provider?
</div>
<VSCodeButton
appearance="primary"
className="w-full mt-3"
disabled={isSwitching || didSwitch}
onClick={handleSwitchToUsageBasedBilling}
>
{isSwitching
? "Switching..."
: didSwitch
? "Switched to Usage-Based billing"
: "Switch to Usage-Based billing"}
</VSCodeButton>
{didSwitch && (
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
Retry the request after switching.
</div>
)}
{error && <div className="text-error text-xs mt-2">{error}</div>}
</div>
);
};
export default ClinePassLimitError;
@@ -3,7 +3,9 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useMemo, useState } from "react"
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
import { useClineAuth } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AccountServiceClient, TaskServiceClient } from "@/services/grpc-client"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
interface CreditLimitErrorProps {
currentBalance: number
@@ -26,7 +28,11 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
totalSpent,
}) => {
const { activeOrganization } = useClineAuth()
const { mode, navigateToSettings } = useExtensionState()
const { handleModeFieldChange } = useApiConfigurationHandlers()
const [fullBuyCreditsUrl, setFullBuyCreditsUrl] = useState<string>("")
const [isSwitchingToClinePass, setIsSwitchingToClinePass] = useState(false)
const [didSwitchToClinePass, setDidSwitchToClinePass] = useState(false)
const dashboardUrl = useMemo(() => {
return buyCreditsUrl ?? (activeOrganization?.organizationId ? DEFAULT_BUY_CREDITS_URL.ORG : DEFAULT_BUY_CREDITS_URL.USER)
@@ -48,6 +54,19 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
fetchCallbackUrl()
}, [dashboardUrl])
const handleSwitchToClinePass = async () => {
setIsSwitchingToClinePass(true)
try {
await handleModeFieldChange({ plan: "planModeApiProvider", act: "actModeApiProvider" }, "cline-pass", mode)
setDidSwitchToClinePass(true)
navigateToSettings("api-config")
} catch (error) {
console.error("Failed to switch to ClinePass:", error)
} finally {
setIsSwitchingToClinePass(false)
}
}
// We have to divide because the balance is stored in microcredits
return (
<div className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)">
@@ -66,6 +85,24 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
</div>
</div>
<div className="mb-2">
<div className="text-(--vscode-descriptionForeground) text-xs mb-2">
Trying to use ClinePass instead of credits?
</div>
<VSCodeButton
appearance="secondary"
className="w-full"
disabled={isSwitchingToClinePass || didSwitchToClinePass}
onClick={handleSwitchToClinePass}>
<span className="codicon codicon-arrow-swap mr-1.5" />
{isSwitchingToClinePass
? "Switching..."
: didSwitchToClinePass
? "Switched to ClinePass"
: "Switch to ClinePass"}
</VSCodeButton>
</div>
<VSCodeButtonLink className="w-full mb-2" href={fullBuyCreditsUrl}>
<span className="codicon codicon-credit-card mr-[6px] text-[14px]" />
Buy Credits
@@ -223,7 +223,7 @@ export const ClinePassEntitlementError: Story = {
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
code: "ENTITLEMENT_ERROR",
modelId: "cline-pass/glm-5.1",
modelId: "cline-pass/glm-5.2",
providerId: "cline-pass",
details: {
code: "ENTITLEMENT_ERROR",
@@ -1,9 +1,17 @@
import type { ClineMessage } from "@shared/ExtensionMessage";
import { ApiProvider } from "@shared/proto/cline/models";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ErrorRow from "./ErrorRow";
const mockSetUserOrganization = vi.hoisted(() => vi.fn());
const mockUpdateApiConfigurationProto = vi.hoisted(() => vi.fn());
const mockApiConfiguration = vi.hoisted(() => ({
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
planModeClinePassModelId: "cline-pass/test-plan-model",
actModeClinePassModelId: "cline-pass/test-act-model",
}));
// Mock the auth context
vi.mock("@/context/ClineAuthContext", () => ({
@@ -30,10 +38,19 @@ vi.mock("@/components/chat/EntitlementError", () => ({
),
}));
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
apiConfiguration: mockApiConfiguration,
}),
}));
vi.mock("@/services/grpc-client", () => ({
AccountServiceClient: {
setUserOrganization: mockSetUserOrganization,
},
ModelsServiceClient: {
updateApiConfigurationProto: mockUpdateApiConfigurationProto,
},
}));
// Mock ClineError
@@ -47,7 +64,9 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
Auth: "auth",
Entitlement: "entitlement",
OrgClinePassRestriction: "orgClinePassRestriction",
ClinePassLimit: "clinePassLimit",
},
extractClinePassLimitMessage: vi.fn((text: string) => text),
}));
describe("ErrorRow", () => {
@@ -61,6 +80,7 @@ describe("ErrorRow", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSetUserOrganization.mockResolvedValue({});
mockUpdateApiConfigurationProto.mockResolvedValue({});
});
it("renders basic error message", () => {
@@ -266,6 +286,83 @@ describe("ErrorRow", () => {
).toBeInTheDocument();
});
it("renders ClinePass limit error and switches to Cline usage-based billing", async () => {
const limitMessage =
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
const mockClineError = {
message: limitMessage,
isErrorType: vi.fn((type) => type === "clinePassLimit"),
providerId: "cline-pass",
_error: {
message: limitMessage,
},
};
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
apiRequestFailedMessage={limitMessage}
errorType="error"
message={mockMessage}
/>,
);
expect(screen.getByTestId("cline-pass-limit-error")).toBeInTheDocument();
expect(screen.getByText(limitMessage)).toBeInTheDocument();
fireEvent.click(screen.getByText("Switch to Usage-Based billing"));
await waitFor(() =>
expect(mockUpdateApiConfigurationProto).toHaveBeenCalledTimes(1),
);
// The proto conversion maps provider id strings to ApiProvider enum values.
const request = mockUpdateApiConfigurationProto.mock.calls[0][0];
expect(request.apiConfiguration.planModeApiProvider).toBe(
ApiProvider.CLINE,
);
expect(request.apiConfiguration.actModeApiProvider).toBe(
ApiProvider.CLINE,
);
expect(
screen.getByText("Switched to Usage-Based billing"),
).toBeInTheDocument();
});
it("does not offer the usage-based billing switch when already on the cline provider", async () => {
const limitMessage =
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
const mockClineError = {
message: limitMessage,
isErrorType: vi.fn((type) => type === "clinePassLimit"),
providerId: "cline",
_error: {
message: limitMessage,
},
};
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
apiRequestFailedMessage={limitMessage}
errorType="error"
message={mockMessage}
/>,
);
expect(
screen.queryByTestId("cline-pass-limit-error"),
).not.toBeInTheDocument();
expect(screen.getByText(limitMessage)).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 type { ClineMessage } from "@shared/ExtensionMessage";
import { isClineProvider } from "@shared/utils/cline";
import { memo } from "react";
import ClinePassLimitError from "@/components/chat/ClinePassLimitError";
import CreditLimitError from "@/components/chat/CreditLimitError";
import EntitlementError from "@/components/chat/EntitlementError";
import OrgClinePassRestrictionError from "@/components/chat/OrgClinePassRestrictionError";
@@ -10,6 +11,7 @@ import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext";
import {
ClineError,
ClineErrorType,
extractClinePassLimitMessage,
} from "../../../../src/services/error/ClineError";
const _errorColor = "var(--vscode-errorForeground)";
@@ -90,6 +92,19 @@ const ErrorRow = memo(
return <OrgClinePassRestrictionError />;
}
// Gated on the ClinePass provider: users already on usage-based
// billing shouldn't be offered a switch to what they're on.
if (
clineError?.isErrorType(ClineErrorType.ClinePassLimit) &&
providerId === "cline-pass"
) {
const detailMessage =
clineError?._error?.details?.message || errorMessage;
const limitMessage =
extractClinePassLimitMessage(detailMessage) ?? detailMessage;
return <ClinePassLimitError message={limitMessage} />;
}
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
@@ -2,12 +2,13 @@ import { BANNER_DATA, BannerAction, BannerActionType, BannerCardData } from "@sh
import { EmptyRequest } from "@shared/proto/cline/common"
import type { Worktree } from "@shared/proto/cline/worktree"
import { TrackWorktreeViewOpenedRequest } from "@shared/proto/cline/worktree"
import { GitBranch } from "lucide-react"
import { GitBranch, Sparkles } from "lucide-react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import BannerCarousel from "@/components/common/BannerCarousel"
import BannerCarousel, { type BannerData } from "@/components/common/BannerCarousel"
import WhatsNewModal from "@/components/common/WhatsNewModal"
import HistoryPreview from "@/components/history/HistoryPreview"
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import HomeHeader from "@/components/welcome/HomeHeader"
import { SuggestedTasks } from "@/components/welcome/SuggestedTasks"
@@ -16,9 +17,12 @@ import { useClineAuth } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AccountServiceClient, StateServiceClient, UiServiceClient, WorktreeServiceClient } from "@/services/grpc-client"
import { convertBannerData } from "@/utils/bannerUtils"
import { buildClinePassSubscriptionUrl } from "@/utils/clinePassSubscription"
import { getCurrentPlatform } from "@/utils/platformUtils"
import { WelcomeSectionProps } from "../../types/chatTypes"
const CLINE_PASS_PROMO_BANNER_ID = "cline-pass-home-promo-v2"
/**
* Welcome section shown when there's no active task
* Includes info banner, announcements, home header, and history preview
@@ -68,6 +72,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
welcomeBanners,
} = useExtensionState()
const { handleFieldsChange } = useApiConfigurationHandlers()
const [dismissedLocalBanners, setDismissedLocalBanners] = useState<Set<string>>(() => new Set())
// Open modal once we have welcome banners
useEffect(() => {
@@ -160,7 +165,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
break
case BannerActionType.SetModel: {
const modelId = action.arg || "anthropic/claude-sonnet-4.5"
const modelId = action.arg || "anthropic/claude-sonnet-5"
const initialModelTab = action.tab || "recommended"
handleFieldsChange({
planModeOpenRouterModelId: modelId,
@@ -210,6 +215,8 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
* Dismissal handler - updates version tracking
*/
const handleBannerDismiss = useCallback((bannerId: string) => {
setDismissedLocalBanners((previous) => new Set(previous).add(bannerId))
// !! Do not continue use these version numbers or add new banners that don't have unique IDs. !!
// Banner versions are **deprecated**. Going forward, we are tracking which banners have
// been dismissed using the **banner ID**.
@@ -225,6 +232,65 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
}
}, [])
const clinePassPromoBanner = useMemo((): BannerData | undefined => {
if (
isBannerDismissed(CLINE_PASS_PROMO_BANNER_ID) ||
dismissedLocalBanners.has(CLINE_PASS_PROMO_BANNER_ID)
) {
return undefined
}
const dismissPromoBanner = () => handleBannerDismiss(CLINE_PASS_PROMO_BANNER_ID)
const subscriptionUrl = buildClinePassSubscriptionUrl(clineUser?.appBaseUrl)
return {
id: CLINE_PASS_PROMO_BANNER_ID,
icon: <Sparkles className="size-4 text-[var(--vscode-charts-yellow)]" />,
title: "Try ClinePass",
description: (
<div className="flex flex-col gap-2">
<p className="m-0">
A $9.99/month subscription for the latest open-weights models, at much lower cost than
paying for direct API access.
</p>
<div>
<Button
onClick={() => {
UiServiceClient.openUrl({ value: subscriptionUrl }).catch(console.error)
}}
size="sm">
Get ClinePass
</Button>
</div>
<button
className="w-fit cursor-pointer border-0 bg-transparent p-0 text-left text-xs text-[var(--vscode-textLink-foreground)] underline hover:cursor-pointer hover:text-[var(--vscode-textLink-activeForeground,var(--vscode-textLink-foreground))]"
onClick={async () => {
try {
await handleFieldsChange({
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
})
navigateToSettings("api-config")
} catch (error) {
console.error("Failed to switch to ClinePass:", error)
}
}}
type="button">
Switch to ClinePass provider to access subscription.
</button>
</div>
),
onDismiss: dismissPromoBanner,
}
}, [
clineUser?.appBaseUrl,
dismissedLocalBanners,
handleBannerDismiss,
handleFieldsChange,
isBannerDismissed,
navigateToSettings,
])
/**
* Build array of active banners for carousel
* Combines hardcoded banners (bannerConfig) with dynamic banners from extension state
@@ -247,8 +313,9 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
)
// Combine both sources: extension state banners first, then hardcoded banners
return [...extensionStateBanners, ...hardcodedBanners]
}, [bannerConfig, banners, clineUser, handleBannerAction, handleBannerDismiss])
const carouselBanners = [...extensionStateBanners, ...hardcodedBanners]
return clinePassPromoBanner ? [clinePassPromoBanner, ...carouselBanners] : carouselBanners
}, [bannerConfig, banners, clinePassPromoBanner, handleBannerAction, handleBannerDismiss])
return (
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
@@ -260,10 +327,10 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
welcomeBanners={welcomeBanners}
/>
<div className="overflow-y-auto flex flex-col pb-2.5">
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
{!showWhatsNewModal && (
<>
<BannerCarousel banners={activeBanners} />
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
{!shouldShowQuickWins && taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
{/* Quick launch worktree button */}
{isGitRepo && worktreesEnabled?.featureFlag && worktreesEnabled?.user && (
@@ -313,6 +380,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
)}
</>
)}
{showWhatsNewModal && <HomeHeader shouldShowQuickWins={shouldShowQuickWins} />}
</div>
<SuggestedTasks shouldShowQuickWins={shouldShowQuickWins} />
@@ -49,17 +49,22 @@ const BannerCardContent: React.FC<BannerCardContentProps> = ({ banner, isActive,
}}>
{/* Title with optional icon */}
<h3
className={cn("font-semibold mb-2 flex items-center gap-2 text-base pr-0", {
className={cn("font-semibold mb-2 flex items-center text-base pr-0", {
"gap-2": banner.icon,
"pr-6": showDismissButton,
})}>
<span className="shrink-0">{banner.icon}</span>
{banner.icon && <span className="shrink-0">{banner.icon}</span>}
{banner.title}
</h3>
{/* Description */}
<div className="text-sm text-description leading-relaxed [&>*:last-child]:mb-0 [&_a]:hover:underline">
{markdownContent}
</div>
{typeof banner.description === "string" ? (
<div className="text-sm text-description leading-relaxed [&>*:last-child]:mb-0 [&_a]:hover:underline">
{markdownContent}
</div>
) : (
<div className="text-sm text-description leading-relaxed">{banner.description}</div>
)}
{/* Action buttons */}
{banner.actions?.length ? (
@@ -1,5 +1,7 @@
import { buildModelInfoNameMap, type ModelInfo, resolveClinePassModelInfo } from "@shared/api"
import { StringRequest } from "@shared/proto/cline/common"
import type { OnboardingModel, OnboardingModelGroup, OpenRouterModelInfo } from "@shared/proto/index.cline"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { AlertCircleIcon, CircleCheckIcon, CircleIcon, ListIcon, LoaderCircleIcon, ZapIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import ClineLogoWhite from "@/assets/ClineLogoWhite"
@@ -7,15 +9,13 @@ 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 { setPendingClinePassSubscribe } from "./clinePassSubscribe"
import { AccountServiceClient, StateServiceClient, UiServiceClient } from "@/services/grpc-client"
import ApiConfigurationSection from "../settings/sections/ApiConfigurationSection"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
import WelcomeView from "../welcome/WelcomeView"
import { setPendingClinePassSubscribe } from "./clinePassSubscribe"
import {
getCapabilities,
getClineUIOnboardingGroups,
@@ -235,7 +235,25 @@ const UserTypeSelectionStep = ({ userType, onSelectUserType, userTypeSelections
</ItemMedia>
<ItemContent className="w-full">
<ItemTitle>{option.title}</ItemTitle>
<ItemDescription>{option.description}</ItemDescription>
<ItemDescription>
{option.description}
{option.learnMoreUrl && (
<>
{" "}
<VSCodeLink
className="inline"
style={{ fontSize: "inherit" }}
onClick={(e) => {
e.stopPropagation()
UiServiceClient.openUrl(
StringRequest.create({ value: option.learnMoreUrl }),
).catch((err) => console.error("Failed to open learn more link:", err))
}}>
Learn more
</VSCodeLink>
</>
)}
</ItemDescription>
</ItemContent>
</Item>
)
@@ -301,8 +319,7 @@ 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 userTypeSelections = useMemo(() => getUserTypeSelections(true), [])
const [stepNumber, setStepNumber] = useState(0)
const [isActionLoading, setIsActionLoading] = useState(false)
@@ -312,7 +329,7 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
const [searchTerm, setSearchTerm] = useState("")
const models = useMemo(() => getClineUIOnboardingGroups(onboardingModels), [onboardingModels])
// ClinePass model IDs (e.g. "cline-pass/glm-5.1") aren't keyed in openRouterModels,
// ClinePass model IDs (e.g. "cline-pass/glm-5.2") aren't keyed in openRouterModels,
// so resolve their info via the slug-based lookup used by ClinePassProvider.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
@@ -22,7 +22,7 @@ describe("getClineUIOnboardingGroups", () => {
it("buckets ClinePass models into the clinePass group", () => {
const result = getClineUIOnboardingGroups(
groupOf([
model("cline-pass/glm-5.1", CLINEPASS_GROUP),
model("cline-pass/glm-5.2", CLINEPASS_GROUP),
model("free-model", "free"),
model("anthropic/claude", "frontier"),
model("z-ai/glm", "open source"),
@@ -31,7 +31,7 @@ describe("getClineUIOnboardingGroups", () => {
expect(result.clinePass).toHaveLength(1)
expect(result.clinePass[0].group).toBe(CLINEPASS_GROUP)
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.1"])
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.2"])
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"])
})
@@ -54,7 +54,7 @@ describe("getRecommendedModelsData", () => {
{
recommended: [],
free: [],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.2", name: "GLM 5.2", description: "", tags: [] }],
},
false,
)
@@ -67,12 +67,12 @@ describe("getRecommendedModelsData", () => {
{
recommended: [],
free: [],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.2", name: "GLM 5.2", description: "", tags: [] }],
},
true,
)
expect(result?.clinePass.map((model) => model.id)).toEqual(["cline-pass/glm-5.1"])
expect(result?.clinePass.map((model) => model.id)).toEqual(["cline-pass/glm-5.2"])
})
it("keeps classic recommended/free responses when the ClinePass feature flag is disabled", () => {
@@ -80,7 +80,7 @@ describe("getRecommendedModelsData", () => {
{
recommended: [{ id: "anthropic/claude", name: "Claude", description: "", tags: [] }],
free: [{ id: "free-model", name: "Free", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.2", name: "GLM 5.2", description: "", tags: [] }],
},
false,
)
@@ -9,6 +9,7 @@ type UserTypeSelection = {
title: string
description: string
type: NEW_USER_TYPE
learnMoreUrl?: string
}
export const STEP_CONFIG = {
@@ -56,9 +57,10 @@ export const STEP_CONFIG = {
} as const
const CLINE_PASS_USER_TYPE_SELECTION: UserTypeSelection = {
title: "ClinePass (Recommended)",
description: "One subscription, curated models, no API keys",
title: "ClinePass",
description: "Low cost subscription plan for best open weights model.",
type: NEW_USER_TYPE.CLINE_PASS,
learnMoreUrl: "https://docs.cline.bot/getting-started/clinepass",
}
const BASE_USER_TYPE_SELECTIONS: UserTypeSelection[] = [
@@ -69,10 +71,9 @@ const BASE_USER_TYPE_SELECTIONS: UserTypeSelection[] = [
/**
* Returns the onboarding user-type options. The free option leads the list and is
* the default selection; ClinePass is inserted as a recommended-but-optional
* choice (labeled "Recommended") right after it, only when the `ext-cline-pass`
* feature flag is enabled. When the flag is off, the classic Free / Frontier /
* BYOK options are shown unchanged.
* the default selection; ClinePass is inserted right after it, only when the
* `ext-cline-pass` feature flag is enabled. When the flag is off, the classic
* Free / Frontier / BYOK options are shown unchanged.
*/
export function getUserTypeSelections(isClinePassEnabled: boolean): UserTypeSelection[] {
if (!isClinePassEnabled) {
@@ -4,9 +4,7 @@ import { EmptyRequest } from "@shared/proto/cline/common"
import type { ClineRecommendedModel } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { useEffect, useMemo, useState } from "react"
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 { CLINEPASS_GROUP, getRecommendedModelsData, type RecommendedModelsData } from "./data-models"
@@ -51,7 +49,6 @@ type FetchState = { status: "loading" } | { status: "success"; data: Recommended
export function useOnboardingModels(): UseOnboardingModelsResult {
const { openRouterModels, clineModels, refreshClineModels } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const [fetchState, setFetchState] = useState<FetchState>({ status: "loading" })
useEffect(() => {
@@ -61,7 +58,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
try {
const response = await ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
if (!cancelled) {
const data = getRecommendedModelsData(response, isClinePassEnabled)
const data = getRecommendedModelsData(response, true)
if (!data) {
setFetchState({ status: "empty" })
} else {
@@ -80,7 +77,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return () => {
cancelled = true
}
}, [isClinePassEnabled])
}, [])
useEffect(() => {
refreshClineModels()
@@ -91,7 +88,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return { ...openRouterModels, ...(clineModels ?? {}) }
}, [openRouterModels, clineModels])
// ClinePass model IDs omit the upstream lab (e.g. "cline-pass/glm-5.1"), so look up
// ClinePass model IDs omit the upstream lab (e.g. "cline-pass/glm-5.2"), so look up
// capabilities via the model slug against the OpenRouter catalog, falling back to
// conservative ClinePass defaults. Mirrors ClinePassProvider's resolution.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
@@ -9,9 +9,7 @@ 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"
@@ -21,7 +19,6 @@ 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"
@@ -102,9 +99,8 @@ 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, { isClinePassEnabled })
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode, { isClinePassEnabled: true })
const { handleModeFieldChange } = useApiConfigurationHandlers()
@@ -145,9 +141,6 @@ 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
@@ -157,15 +150,24 @@ const ApiOptions = ({
// Filter by remote config if remoteConfiguredProviders is set
const remoteProviders: string[] = remoteConfigSettings?.remoteConfiguredProviders || []
if (remoteProviders.length > 0) {
providers = providers.filter((option) => remoteProviders.includes(option.value))
const effectiveRemoteProviders =
remoteProviders.includes("cline-pass") && !remoteProviders.includes("cline")
? [...remoteProviders, "cline"]
: remoteProviders
providers = providers.filter((option) => effectiveRemoteProviders.includes(option.value))
}
return providers
}, [isClinePassEnabled, remoteConfigSettings])
}, [remoteConfigSettings])
const getProviderDisplayLabel = useCallback((option: (typeof PROVIDERS.list)[number]) => {
return option.value === "cline" ? "Cline Usage-Billing" : option.label
}, [])
const currentProviderLabel = useMemo(() => {
return providerOptions.find((option) => option.value === selectedProvider)?.label || selectedProvider
}, [providerOptions, selectedProvider])
const selectedOption = providerOptions.find((option) => option.value === selectedProvider)
return selectedOption ? getProviderDisplayLabel(selectedOption) : selectedProvider
}, [getProviderDisplayLabel, providerOptions, selectedProvider])
// Sync search term with current provider when not searching
useEffect(() => {
@@ -177,13 +179,19 @@ const ApiOptions = ({
const searchableItems = useMemo(() => {
return providerOptions.map((option) => ({
value: option.value,
html: option.label,
html: getProviderDisplayLabel(option),
searchText:
option.value === "cline"
? "Cline Usage Billing usage based pay as you go"
: option.value === "cline-pass"
? "ClinePass subscription included models"
: option.label,
}))
}, [providerOptions])
}, [getProviderDisplayLabel, providerOptions])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"],
keys: ["html", "searchText"],
threshold: 0.3,
shouldSort: true,
isCaseSensitive: false,
@@ -366,20 +374,17 @@ const ApiOptions = ({
<HicapProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
{apiConfiguration && selectedProvider === "cline" && (
{apiConfiguration && (selectedProvider === "cline" || selectedProvider === "cline-pass") && (
<ClineProvider
currentMode={currentMode}
initialModelTab={initialModelTab}
isClinePassEnabled={isClinePassEnabled}
isClinePassEnabled={true}
isPopup={isPopup}
selectedProvider={selectedProvider}
showModelOptions={showModelOptions}
/>
)}
{apiConfiguration && isClinePassEnabled && selectedProvider === "cline-pass" && (
<ClinePassProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
{apiConfiguration && selectedProvider === "asksage" && (
<AskSageProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
@@ -29,7 +29,7 @@ export const ClineAccountInfoCard = () => {
<div className="max-w-[600px]">
{user ? (
<VSCodeButton appearance="secondary" onClick={handleShowAccount}>
View Billing & Usage
View Billing History
</VSCodeButton>
) : (
<div>
@@ -37,7 +37,7 @@ export const ClineAccountInfoCard = () => {
Sign Up with Cline
{isLoading && (
<span className="ml-1 animate-spin">
<span className="codicon codicon-refresh"></span>
<span className="codicon codicon-refresh" />
</span>
)}
</VSCodeButton>
@@ -47,6 +47,13 @@ const StarIcon = ({ isFavorite, onClick }: { isFavorite: boolean; onClick: (e: R
)
}
export interface FeaturedModelTab {
label: string
models: FeaturedModelCardEntry[]
// Optional explanatory copy shown between the tab bar and the model cards
description?: string
}
export interface ClineModelPickerProps {
isPopup?: boolean
currentMode: Mode
@@ -58,12 +65,17 @@ export interface ClineModelPickerProps {
models?: Record<string, ModelInfo>
isClinePassEnabled?: boolean
showFeaturedModels?: boolean
// Custom featured tabs (e.g. ClinePass "Subscribed"/"Free") shown instead of the
// built-in Recommended/Free tabs
featuredTabs?: FeaturedModelTab[]
}
interface FeaturedModelCardEntry {
export interface FeaturedModelCardEntry {
id: string
description: string
label: string
// Shown on the card instead of the id (e.g. ClinePass ids without their prefix)
displayName?: string
}
const CLINE_RECOMMENDED_MODELS_RETRY_DELAY_MS = 5000
@@ -72,7 +84,7 @@ function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
function toFeaturedModelCardEntry(
export function toFeaturedModelCardEntry(
model: Pick<ClineRecommendedModel, "id" | "description" | "tags">,
fallbackLabel: string,
): FeaturedModelCardEntry | null {
@@ -109,6 +121,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
models,
isClinePassEnabled = true,
showFeaturedModels = true,
featuredTabs,
}) => {
const { handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
const { apiConfiguration, favoritedModelIds, clineModels, openRouterModels, refreshClineModels } = useExtensionState()
@@ -150,6 +163,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
[freeClineModelIds],
)
const [activeTab, setActiveTab] = useState<"recommended" | "free">(initialTab ?? "recommended")
const [activeFeaturedTabIndex, setActiveFeaturedTabIndex] = useState(0)
const recommendedModels = useMemo(
() => (clineRecommendedModels.length > 0 ? clineRecommendedModels : RECOMMENDED_MODELS_FALLBACK),
[clineRecommendedModels],
@@ -230,11 +244,26 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
const currentModelId = resolveModelId(configuredModelId)
setActiveTab(freeClineModelIdSet.has(normalizeModelId(currentModelId)) ? "free" : "recommended")
}, [configuredModelId, freeClineModelIdSet, initialTab, resolveModelId])
// Keep the active custom tab on the tab containing the configured model
useEffect(() => {
if (!featuredTabs) {
return
}
const currentModelId = resolveModelId(configuredModelId)
const tabIndex = featuredTabs.findIndex((tab) => tab.models.some((model) => model.id === currentModelId))
if (tabIndex >= 0) {
setActiveFeaturedTabIndex(tabIndex)
}
}, [configuredModelId, featuredTabs, resolveModelId])
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
const handleModelChange = (newModelId: string) => {
const handleModelChange = (rawModelId: string) => {
// When a fixed models map is provided (ClinePass), only ids in the map may be
// stored — otherwise the host would send arbitrary typed text to the API.
const newModelId = models && !(rawModelId in models) ? resolveModelId(rawModelId) : rawModelId
setSearchTerm(newModelId)
handleModeFieldsChange(
@@ -414,6 +443,8 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
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-5") ||
selectedModelIdLower.includes("claude-5-sonnet") ||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
selectedModelIdLower.includes("claude-sonnet-4-6") ||
selectedModelIdLower.includes("claude-4.6-sonnet") ||
@@ -442,7 +473,52 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
<span style={{ fontWeight: 500 }}>Model</span>
</label>
{showFeaturedModels && (
{showFeaturedModels && featuredTabs && (
<>
{/* Custom Tabs (e.g. ClinePass Subscribed/Free) */}
<TabsContainer style={{ marginTop: 4 }}>
{featuredTabs.map((tab, index) => (
<Tab
active={activeFeaturedTabIndex === index}
key={tab.label}
onClick={() => setActiveFeaturedTabIndex(index)}>
{tab.label}
</Tab>
))}
</TabsContainer>
{/* Tab Description */}
{featuredTabs[activeFeaturedTabIndex]?.description && (
<p
style={{
fontSize: "11px",
margin: "4px 0 6px 0",
color: "var(--vscode-descriptionForeground)",
}}>
{featuredTabs[activeFeaturedTabIndex].description}
</p>
)}
{/* Model Cards */}
<div style={{ marginBottom: "6px" }}>
{featuredTabs[activeFeaturedTabIndex]?.models.map((model) => (
<FeaturedModelCard
description={model.description}
isSelected={selectedModelId === model.id}
key={model.id}
label={model.label}
modelId={model.displayName ?? model.id}
onClick={() => {
handleModelChange(model.id)
setIsDropdownVisible(false)
}}
/>
))}
</div>
</>
)}
{showFeaturedModels && !featuredTabs && (
<>
{/* Tabs */}
<TabsContainer style={{ marginTop: 4 }}>
@@ -570,6 +646,14 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Sonnet 5 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-sonnet-5"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 4.8 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`}
@@ -654,8 +738,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
marginTop: 0,
color: "var(--vscode-descriptionForeground)",
}}>
The extension automatically fetches the latest Cline model list. If you're unsure which model to choose, Cline
works best with <strong>anthropic/claude-sonnet-4.5</strong>.
The extension automatically fetches the latest Cline model list.
</p>
)}
</div>
@@ -221,6 +221,8 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
Object.entries(openRouterModels)?.some(([id, m]) => id === selectedModelId && m.thinkingConfig) ||
selectedModelIdLower.includes("claude-haiku-4.5") ||
selectedModelIdLower.includes("claude-4.5-haiku") ||
selectedModelIdLower.includes("claude-sonnet-5") ||
selectedModelIdLower.includes("claude-5-sonnet") ||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
selectedModelIdLower.includes("claude-sonnet-4-6") ||
selectedModelIdLower.includes("claude-4.6-sonnet") ||
@@ -331,6 +333,14 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Sonnet 5 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-sonnet-5"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 4.8 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`}
@@ -421,9 +431,9 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
</VSCodeLink>
If you're unsure which model to choose, Cline works best with{" "}
<VSCodeLink
onClick={() => handleModelChange("anthropic/claude-sonnet-4.6")}
onClick={() => handleModelChange("anthropic/claude-sonnet-5")}
style={{ display: "inline", fontSize: "inherit" }}>
anthropic/claude-sonnet-4.6.
anthropic/claude-sonnet-5.
</VSCodeLink>
You can also try searching "free" for no-cost options currently available.
</p>
@@ -179,6 +179,8 @@ const VercelModelPicker: React.FC<VercelModelPickerProps> = ({ isPopup, currentM
return (
selectedModelIdLower.includes("claude-haiku-4.5") ||
selectedModelIdLower.includes("claude-4.5-haiku") ||
selectedModelIdLower.includes("claude-sonnet-5") ||
selectedModelIdLower.includes("claude-5-sonnet") ||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
selectedModelIdLower.includes("claude-sonnet-4-6") ||
selectedModelIdLower.includes("claude-4.6-sonnet") ||
@@ -189,8 +189,9 @@ export const ModelInfoView = ({
const [advancedExpanded, setAdvancedExpanded] = useState(false)
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
const hidePricing = selectedModelId.trim().toLowerCase().startsWith("cline-pass/")
const hasThinkingConfig = hasThinkingBudget(modelInfo)
const hasTiers = !!modelInfo.tiers && modelInfo.tiers.length > 0
const hasTiers = !hidePricing && !!modelInfo.tiers && modelInfo.tiers.length > 0
// Capability checks
const hasImages = supportsImages(modelInfo)
@@ -198,7 +199,8 @@ export const ModelInfoView = ({
const hasCaching = !isGemini && supportsPromptCache(modelInfo)
// Check if we have cache pricing to show in Advanced section
const hasCachePricing = modelInfo.supportsPromptCache && (modelInfo.cacheWritesPrice || modelInfo.cacheReadsPrice)
const hasCachePricing =
!hidePricing && modelInfo.supportsPromptCache && (modelInfo.cacheWritesPrice || modelInfo.cacheReadsPrice)
return (
<div style={{ marginTop: 4 }}>
@@ -215,13 +217,13 @@ export const ModelInfoView = ({
<InfoValue>{formatCompactContext(modelInfo.contextWindow)}</InfoValue>
</InfoItem>
)}
{modelInfo.inputPrice !== undefined && (
{!hidePricing && modelInfo.inputPrice !== undefined && (
<InfoItem>
<InfoLabel>Input: </InfoLabel>
<InfoValue>{formatCompactPrice(modelInfo.inputPrice)}</InfoValue>
</InfoItem>
)}
{modelInfo.outputPrice !== undefined && (
{!hidePricing && modelInfo.outputPrice !== undefined && (
<InfoItem>
<InfoLabel>Output: </InfoLabel>
<InfoValue>
@@ -15,6 +15,8 @@ import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandler
// Anthropic models that support thinking/reasoning mode
export const SUPPORTED_ANTHROPIC_THINKING_MODELS = [
"claude-sonnet-5",
`claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`,
"claude-sonnet-4-6",
`claude-sonnet-4-6${CLAUDE_SONNET_1M_SUFFIX}`,
"claude-3-7-sonnet-20250219",
@@ -90,6 +92,14 @@ export const AnthropicProvider = ({ showModelOptions, isPopup, currentMode }: An
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Sonnet 5 */}
<ContextWindowSwitcher
base1mModelId={`claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="claude-sonnet-5"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 4.6 */}
<ContextWindowSwitcher
base1mModelId={`claude-opus-4-6${CLAUDE_SONNET_1M_SUFFIX}`}
@@ -24,6 +24,8 @@ import { getModeSpecificFields, normalizeApiConfiguration } from "../utils/provi
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
export const SUPPORTED_BEDROCK_THINKING_MODELS = [
"anthropic.claude-sonnet-5",
`anthropic.claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`,
"anthropic.claude-sonnet-4-6",
`anthropic.claude-sonnet-4-6${CLAUDE_SONNET_1M_SUFFIX}`,
"anthropic.claude-3-7-sonnet-20250219-v1:0",
@@ -13,6 +13,7 @@ const SUPPORTED_CLAUDE_CODE_THINKING_MODELS = [
...SUPPORTED_ANTHROPIC_THINKING_MODELS,
"sonnet",
"sonnet[1m]",
"claude-sonnet-5[1m]",
"claude-fable-5[1m]",
"claude-opus-4-8[1m]",
"claude-opus-4-7[1m]",
@@ -1,46 +1,89 @@
import {
buildModelInfoNameMap,
clinePassDefaultModelId,
clinePassModelInfoSaneDefaults,
clinePassModels,
type ModelInfo,
resolveClinePassModelInfo,
} from "@shared/api"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { ClineRecommendedModel } from "@shared/proto/cline/models"
import type { Mode } from "@shared/storage/types"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { ModelsServiceClient } from "@/services/grpc-client"
import { ClineAccountInfoCard } from "../ClineAccountInfoCard"
import ClineModelPicker from "../ClineModelPicker"
import { ClineProvider } from "./ClineProvider"
import ClineModelPicker, {
type FeaturedModelCardEntry,
type FeaturedModelTab,
toFeaturedModelCardEntry,
} from "../ClineModelPicker"
import { getModeSpecificFields } from "../utils/providerUtils"
export const ClinePassProvider: typeof ClineProvider = (props) => {
const { openRouterModels } = useExtensionState()
interface ClinePassProviderProps {
showModelOptions: boolean
isPopup?: boolean
currentMode: Mode
showAccountCard?: boolean
}
const CLINE_PASS_MODEL_FIELD_PAIRS = {
modelId: { plan: "planModeClinePassModelId", act: "actModeClinePassModelId" },
modelInfo: { plan: "planModeClinePassModelInfo", act: "actModeClinePassModelInfo" },
} as const
const FREE_TAB_DESCRIPTION =
"Try these models with limited free usage, included at no cost and separate from your ClinePass quota."
function zeroPriced(info: ModelInfo): ModelInfo {
return {
...info,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
}
}
export const ClinePassProvider = ({
showModelOptions,
isPopup,
currentMode,
showAccountCard = true,
}: ClinePassProviderProps) => {
const { apiConfiguration, openRouterModels, clineModels, refreshClineModels } = useExtensionState()
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
const [clinePassRawModels, setClinePassRawModels] = useState<ClineRecommendedModel[]>([])
const [clinePassRecommendedModels, setClinePassRecommendedModels] = useState<Record<string, ModelInfo> | undefined>(undefined)
const [clineFreeModels, setClineFreeModels] = useState<ClineRecommendedModel[]>([])
const refreshClinePassModels = useCallback(async () => {
try {
const response = await ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
const clinePassResponseModels = (response.clinePass ?? []).filter((model) => model.id)
const models = Object.fromEntries(
(response.clinePass ?? [])
.filter((model) => model.id)
.map((model) => {
// ClinePass 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,
},
]
}),
clinePassResponseModels.map((model) => {
// ClinePass model IDs omit the upstream lab, so look up capabilities using
// the model slug (for example, glm-5.2 instead of cline-pass/glm-5.2).
// If the model is not in OpenRouter yet, use conservative generic defaults
// instead of copying GLM-5.2-specific context/max-token values.
const fallback = resolveClinePassModelInfo(model.id, openRouterModelsByName)
return [
model.id,
{
...fallback,
name: model.name || fallback.name || model.id,
// The info panel shows the full catalog description; the endpoint's
// short blurb is only for the featured cards
description: fallback.description || model.description,
},
]
}),
)
setClinePassRawModels(clinePassResponseModels)
setClinePassRecommendedModels(Object.keys(models).length > 0 ? models : undefined)
setClineFreeModels((response.free ?? []).filter((model) => model.id))
} catch (error) {
console.error("Failed to refresh ClinePass models:", error)
}
@@ -50,7 +93,54 @@ export const ClinePassProvider: typeof ClineProvider = (props) => {
void refreshClinePassModels()
}, [refreshClinePassModels])
const clinePassModelOptions = clinePassRecommendedModels ?? clinePassModels
// The picker skips its own catalog refresh when a models map is provided, but the
// free-model entries below resolve their capabilities from the cline catalog.
useEffect(() => {
refreshClineModels()
}, [refreshClineModels])
const freeRecommendedModels = useMemo(
() => (clineFreeModels.length > 0 ? clineFreeModels : CLINE_RECOMMENDED_MODELS_FALLBACK.free),
[clineFreeModels],
)
// Free models are OpenRouter-style ids billed at $0, so resolve their info by full id
// from the dynamic catalogs and store them zero-priced.
const freeModelEntries = useMemo(() => {
const modelCatalog: Record<string, ModelInfo> = { ...openRouterModels, ...clineModels }
return Object.fromEntries(
freeRecommendedModels
.filter((model) => model.id)
.map((model) => {
const base = modelCatalog[model.id] ?? clinePassModelInfoSaneDefaults
return [
model.id,
zeroPriced({
...base,
name: model.name || base.name || model.id,
// The info panel shows the full catalog description; the endpoint's
// short blurb is only for the featured cards
description: base.description || model.description,
}),
]
}),
)
}, [freeRecommendedModels, openRouterModels, clineModels])
const { clinePassModelId: configuredClinePassModelId, clinePassModelInfo: configuredClinePassModelInfo } =
getModeSpecificFields(apiConfiguration, currentMode)
const clinePassModelOptions = useMemo(() => {
// ClinePass entries first so the default-model fallback below stays a pass model.
const merged: Record<string, ModelInfo> = { ...(clinePassRecommendedModels ?? clinePassModels), ...freeModelEntries }
// Keep a previously selected model visible even if it later drops out of the
// endpoint's buckets, so the picker doesn't display a model the host won't send.
if (configuredClinePassModelId && !(configuredClinePassModelId in merged) && configuredClinePassModelInfo) {
merged[configuredClinePassModelId] = configuredClinePassModelInfo
}
return merged
}, [clinePassRecommendedModels, freeModelEntries, configuredClinePassModelId, configuredClinePassModelInfo])
const clinePassDefaultModel = useMemo(() => {
if (!clinePassModelOptions) {
return undefined
@@ -61,20 +151,59 @@ export const ClinePassProvider: typeof ClineProvider = (props) => {
: (Object.keys(clinePassModelOptions)[0] ?? clinePassDefaultModelId)
}, [clinePassModelOptions])
// Subscription models show their endpoint description but no label chip — the
// whole list is included with the plan, so a per-card tag adds nothing
const subscribedModelCards = useMemo<FeaturedModelCardEntry[]>(() => {
const models =
clinePassRawModels.length > 0
? clinePassRawModels.map((model) => ({ id: model.id, description: model.description }))
: Object.entries(clinePassRecommendedModels ?? clinePassModels).map(([id, info]) => ({
id,
description: info.description,
}))
return models.map((model) => ({
id: model.id,
displayName: model.id.replace(/^cline-pass\//, ""),
label: "",
description: model.description || "",
}))
}, [clinePassRawModels, clinePassRecommendedModels])
const freeModelCards = useMemo(
() =>
freeRecommendedModels
.map((model) => toFeaturedModelCardEntry(model, "FREE"))
.filter((model): model is FeaturedModelCardEntry => model !== null),
[freeRecommendedModels],
)
const featuredTabs = useMemo<FeaturedModelTab[]>(() => {
const tabs: FeaturedModelTab[] = [{ label: "Subscribed", models: subscribedModelCards }]
if (freeModelCards.length > 0) {
tabs.push({ label: "Free", models: freeModelCards, description: FREE_TAB_DESCRIPTION })
}
return tabs
}, [subscribedModelCards, freeModelCards])
return (
<div>
<div style={{ marginBottom: 14, marginTop: 4 }}>
<ClineAccountInfoCard />
</div>
{showAccountCard && (
<div style={{ marginBottom: 14, marginTop: 4 }}>
<ClineAccountInfoCard />
</div>
)}
<ClineModelPicker
{...props}
defaultModelId={clinePassDefaultModel}
modelIdFieldPair={{ plan: "planModeClinePassModelId", act: "actModeClinePassModelId" }}
modelInfoFieldPair={{ plan: "planModeClinePassModelInfo", act: "actModeClinePassModelInfo" }}
models={clinePassModelOptions}
showFeaturedModels={false}
/>
{showModelOptions && (
<ClineModelPicker
currentMode={currentMode}
defaultModelId={clinePassDefaultModel}
featuredTabs={featuredTabs}
isPopup={isPopup}
modelIdFieldPair={CLINE_PASS_MODEL_FIELD_PAIRS.modelId}
modelInfoFieldPair={CLINE_PASS_MODEL_FIELD_PAIRS.modelInfo}
models={clinePassModelOptions}
/>
)}
</div>
)
}
@@ -1,18 +1,28 @@
import { Mode } from "@shared/storage/types"
import type { ApiProvider } from "@shared/api"
import type { Mode } from "@shared/storage/types"
import styled from "styled-components"
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
import { useClineAuth } from "@/context/ClineAuthContext"
import { buildClinePassSubscriptionUrl } from "@/utils/clinePassSubscription"
import { ClineAccountInfoCard } from "../ClineAccountInfoCard"
import ClineModelPicker from "../ClineModelPicker"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
import { ClinePassProvider } from "./ClinePassProvider"
/**
* Props for the ClineProvider component
*/
interface ClineProviderProps {
export interface ClineProviderProps {
showModelOptions: boolean
isPopup?: boolean
currentMode: Mode
initialModelTab?: "recommended" | "free"
isClinePassEnabled?: boolean
selectedProvider?: ApiProvider
}
type ClineBillingRoute = "cline" | "cline-pass"
/**
* The Cline provider configuration component
*/
@@ -22,23 +32,128 @@ export const ClineProvider = ({
currentMode,
initialModelTab,
isClinePassEnabled,
selectedProvider,
}: ClineProviderProps) => {
const { handleModeFieldChange } = useApiConfigurationHandlers()
const { clineUser } = useClineAuth()
const activeRoute: ClineBillingRoute = selectedProvider === "cline-pass" && isClinePassEnabled ? "cline-pass" : "cline"
const clinePassSubscribeUrl = clineUser ? buildClinePassSubscriptionUrl(clineUser.appBaseUrl) : undefined
const handleRouteChange = async (route: ClineBillingRoute) => {
if (route === activeRoute) {
return
}
await handleModeFieldChange({ plan: "planModeApiProvider", act: "actModeApiProvider" }, route, currentMode)
}
return (
<div>
{/* Cline Account Info Card */}
<div style={{ marginBottom: 14, marginTop: 4 }}>
<ClineAccountInfoCard />
</div>
{isClinePassEnabled && (
<RouteContainer>
<RouteStatus>
{activeRoute === "cline-pass" ? (
<>
ClinePass is a low cost subscription plan including usage of the best open weights models.{" "}
<RouteLink
href="#"
onClick={(event) => {
event.preventDefault()
handleRouteChange("cline").catch((error) => console.error("Failed to switch to Cline:", error))
}}>
Switch to Cline Usage-Billing for other models.
</RouteLink>
</>
) : (
<>
Usage-Billing models bill to your Cline account balance.{" "}
<RouteLink
href="#"
onClick={(event) => {
event.preventDefault()
handleRouteChange("cline-pass").catch((error) =>
console.error("Failed to switch to ClinePass:", error),
)
}}>
Switch to ClinePass provider to access subscription.
</RouteLink>
</>
)}
</RouteStatus>
<RouteActions>
{activeRoute === "cline" && <ClineAccountInfoCard />}
{activeRoute === "cline-pass" && (
clinePassSubscribeUrl ? (
<VSCodeButtonLink appearance="secondary" href={clinePassSubscribeUrl}>
Manage ClinePass or See Usage
</VSCodeButtonLink>
) : (
<ClineAccountInfoCard />
)
)}
</RouteActions>
</RouteContainer>
)}
{!isClinePassEnabled && (
<StandaloneRouteActions>
<ClineAccountInfoCard />
</StandaloneRouteActions>
)}
{showModelOptions && (
<ClineModelPicker
currentMode={currentMode}
initialTab={initialModelTab}
isClinePassEnabled={isClinePassEnabled}
isPopup={isPopup}
showProviderRouting={true}
/>
activeRoute === "cline-pass" ? (
<ClinePassProvider
currentMode={currentMode}
isPopup={isPopup}
showAccountCard={false}
showModelOptions={showModelOptions}
/>
) : (
<ClineModelPicker
currentMode={currentMode}
initialTab={initialModelTab}
isClinePassEnabled={isClinePassEnabled}
isPopup={isPopup}
showProviderRouting={true}
/>
)
)}
</div>
)
}
const RouteContainer = styled.div`
margin-bottom: 10px;
`
const RouteStatus = styled.div`
font-size: 11px;
line-height: 1.35;
color: var(--vscode-descriptionForeground);
`
const RouteLink = styled.a`
color: var(--vscode-textLink-foreground);
font: inherit;
line-height: inherit;
cursor: pointer !important;
text-decoration: none;
&:hover {
color: var(--vscode-textLink-activeForeground, var(--vscode-textLink-foreground));
cursor: pointer !important;
text-decoration: underline;
}
`
const RouteActions = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
`
const StandaloneRouteActions = styled(RouteActions)`
margin: 4px 0 14px;
`
@@ -4,9 +4,16 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
import { ApiKeyField } from "../common/ApiKeyField"
import { ModelInfoView } from "../common/ModelInfoView"
import { ModelSelector } from "../common/ModelSelector"
import ReasoningEffortSelector from "../ReasoningEffortSelector"
import { normalizeApiConfiguration } from "../utils/providerUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
const DEEPSEEK_REASONING_EFFORT_MODELS = new Set([
"deepseek-v4-flash",
"deepseek-v4-pro",
"deepseek-reasoner",
])
/**
* Props for the DeepSeekProvider component
*/
@@ -25,6 +32,7 @@ export const DeepSeekProvider = ({ showModelOptions, isPopup, currentMode }: Dee
// Get the normalized configuration
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, currentMode)
const showReasoningEffort = DEEPSEEK_REASONING_EFFORT_MODELS.has(selectedModelId)
return (
<div>
@@ -50,6 +58,8 @@ export const DeepSeekProvider = ({ showModelOptions, isPopup, currentMode }: Dee
selectedModelId={selectedModelId}
/>
{showReasoningEffort && <ReasoningEffortSelector currentMode={currentMode} />}
<ModelInfoView isPopup={isPopup} modelInfo={selectedModelInfo} selectedModelId={selectedModelId} />
</>
)}
@@ -25,6 +25,8 @@ interface VertexProviderProps {
// Vertex models that support thinking
const SUPPORTED_THINKING_MODELS = [
"claude-sonnet-5",
"claude-sonnet-5:1m",
"claude-sonnet-4-6",
"claude-sonnet-4-6:1m",
"claude-haiku-4-5@20251001",
@@ -0,0 +1,87 @@
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import { clinePassDefaultModelId, clinePassModels } from "@shared/api"
import { describe, expect, it } from "vitest"
import { normalizeApiConfiguration, syncModeConfigurations } from "../providerUtils"
describe("providerUtils", () => {
const freeModelInfo: ModelInfo = {
maxTokens: 32_768,
contextWindow: 256_000,
supportsImages: false,
supportsPromptCache: false,
supportsReasoning: true,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
description: "A free model",
}
describe("normalizeApiConfiguration cline-pass", () => {
it("passes a free (non cline-pass prefixed) model id through with its stored info", () => {
const apiConfiguration: ApiConfiguration = {
actModeApiProvider: "cline-pass",
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
actModeClinePassModelInfo: freeModelInfo,
}
const normalized = normalizeApiConfiguration(apiConfiguration, "act", { isClinePassEnabled: true })
expect(normalized.selectedProvider).toBe("cline-pass")
expect(normalized.selectedModelId).toBe("kwaipilot/kat-coder-pro")
expect(normalized.selectedModelInfo).toEqual(freeModelInfo)
})
it("passes a :free suffixed model id through with its stored info", () => {
const apiConfiguration: ApiConfiguration = {
actModeApiProvider: "cline-pass",
actModeClinePassModelId: "arcee-ai/trinity-large-preview:free",
actModeClinePassModelInfo: freeModelInfo,
}
const normalized = normalizeApiConfiguration(apiConfiguration, "act", { isClinePassEnabled: true })
expect(normalized.selectedModelId).toBe("arcee-ai/trinity-large-preview:free")
expect(normalized.selectedModelInfo).toEqual(freeModelInfo)
})
it("keeps cline-pass prefixed ids resolved against the static model table", () => {
const apiConfiguration: ApiConfiguration = {
actModeApiProvider: "cline-pass",
actModeClinePassModelId: "cline-pass/glm-5.2",
}
const normalized = normalizeApiConfiguration(apiConfiguration, "act", { isClinePassEnabled: true })
expect(normalized.selectedModelId).toBe("cline-pass/glm-5.2")
expect(normalized.selectedModelInfo).toEqual(clinePassModels["cline-pass/glm-5.2"])
})
it("falls back to the default pass model when no model id is configured", () => {
const apiConfiguration: ApiConfiguration = {
actModeApiProvider: "cline-pass",
}
const normalized = normalizeApiConfiguration(apiConfiguration, "act", { isClinePassEnabled: true })
expect(normalized.selectedModelId).toBe(clinePassDefaultModelId)
expect(normalized.selectedModelInfo).toEqual(clinePassModels[clinePassDefaultModelId])
})
})
describe("syncModeConfigurations cline-pass", () => {
it("copies a free model id and info across plan and act modes", async () => {
const apiConfiguration: ApiConfiguration = {
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
actModeClinePassModelInfo: freeModelInfo,
}
let updates: Partial<ApiConfiguration> | undefined
await syncModeConfigurations(apiConfiguration, "act", async (fields) => {
updates = fields
})
expect(updates?.planModeClinePassModelId).toBe("kwaipilot/kat-coder-pro")
expect(updates?.planModeClinePassModelInfo).toEqual(freeModelInfo)
expect(updates?.actModeClinePassModelId).toBe("kwaipilot/kat-coder-pro")
})
})
})
@@ -9,6 +9,7 @@ import {
basetenModels,
bedrockDefaultModelId,
bedrockModels,
buildModelInfoNameMap,
cerebrasDefaultModelId,
cerebrasModels,
claudeCodeDefaultModelId,
@@ -354,23 +355,25 @@ export function normalizeApiConfiguration(
currentMode === "plan"
? apiConfiguration?.planModeClinePassModelId
: apiConfiguration?.actModeClinePassModelId;
const clinePassModelId = configuredClinePassModelId?.startsWith(
"cline-pass/",
)
? configuredClinePassModelId
: clinePassDefaultModelId;
// ClinePass users may also select Cline free models (OpenRouter-style ids
// without the cline-pass/ prefix), so pass any configured id through.
const clinePassModelId =
configuredClinePassModelId || clinePassDefaultModelId;
const clinePassModelInfo =
(currentMode === "plan"
currentMode === "plan"
? apiConfiguration?.planModeClinePassModelInfo
: apiConfiguration?.actModeClinePassModelInfo) ||
resolveClinePassModelInfo(
clinePassModelId,
options.clinePassModelInfoByName,
);
: apiConfiguration?.actModeClinePassModelInfo;
const clinePassModelInfoByName = clinePassModelInfo
? buildModelInfoNameMap({ [clinePassModelId]: clinePassModelInfo })
: options.clinePassModelInfoByName;
const resolvedClinePassModelInfo = resolveClinePassModelInfo(
clinePassModelId,
clinePassModelInfoByName,
);
return {
selectedProvider: provider,
selectedModelId: clinePassModelId,
selectedModelInfo: clinePassModelInfo,
selectedModelInfo: resolvedClinePassModelInfo,
};
}
case "openai": {
@@ -1,3 +0,0 @@
// Webview copies of feature-flag strings. Must match the extension's FeatureFlag
// enum, which can't be imported here (it pulls in Node-only deps).
export const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
@@ -0,0 +1,14 @@
const DEFAULT_CLINE_APP_BASE_URL = "https://app.cline.bot"
const CLINE_PASS_SUBSCRIPTION_PATH = "dashboard/subscription"
export function buildClinePassSubscriptionUrl(appBaseUrl?: string): string {
try {
const baseUrl = appBaseUrl || DEFAULT_CLINE_APP_BASE_URL
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`
const url = new URL(CLINE_PASS_SUBSCRIPTION_PATH, base)
url.searchParams.set("personal", "true")
return url.toString()
} catch {
return `${DEFAULT_CLINE_APP_BASE_URL}/${CLINE_PASS_SUBSCRIPTION_PATH}?personal=true`
}
}
+5 -1
View File
@@ -1,4 +1,5 @@
import { ApiConfiguration, clinePassDefaultModelId, clinePassModels, ModelInfo, openRouterDefaultModelId } from "@shared/api"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
import { Mode } from "@shared/storage/types"
import { getModeSpecificFields } from "@/components/settings/utils/providerUtils"
@@ -222,7 +223,10 @@ export function validateModelId(
}
if (
!Object.keys(clinePassModels).includes(clinePassResolvedModelId) &&
!clinePassResolvedModelId.startsWith("cline-pass/")
!clinePassResolvedModelId.startsWith("cline-pass/") &&
// ClinePass users may also select Cline free models (OpenRouter-style ids)
!(clineModels && Object.keys(clineModels).includes(clinePassResolvedModelId)) &&
!CLINE_RECOMMENDED_MODELS_FALLBACK.free.some((model) => model.id === clinePassResolvedModelId)
) {
return "The model ID you provided is not available. Please choose a different model."
}
+150 -119
View File
@@ -1,10 +1,10 @@
/// <reference types="vitest/config" />
import { writeFileSync } from "node:fs"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react-swc"
import { resolve } from "path"
import { defineConfig, type Plugin, ViteDevServer } from "vite"
import { writeFileSync } from "node:fs";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { resolve } from "path";
import { defineConfig, loadEnv, type Plugin, type ViteDevServer } from "vite";
// Custom plugin to write the server port to a file
const writePortToFile = (): Plugin => {
@@ -12,135 +12,166 @@ const writePortToFile = (): Plugin => {
name: "write-port-to-file",
configureServer(server: ViteDevServer) {
server.httpServer?.once("listening", () => {
const address = server.httpServer?.address()
const port = typeof address === "object" && address ? address.port : null
const address = server.httpServer?.address();
const port =
typeof address === "object" && address ? address.port : null;
if (port) {
const portFilePath = resolve(__dirname, ".vite-port")
writeFileSync(portFilePath, port.toString())
const portFilePath = resolve(__dirname, ".vite-port");
writeFileSync(portFilePath, port.toString());
} else {
console.warn("[writePortToFile] Could not determine server port")
console.warn("[writePortToFile] Could not determine server port");
}
})
});
},
}
}
};
};
const isDevBuild = process.argv.includes("--dev-build")
const isDevBuild = process.argv.includes("--dev-build");
// Valid platforms, these should the keys in platform-configs.json
const VALID_PLATFORMS = ["vscode", "standalone"]
const platform = process.env.PLATFORM || "vscode" // Default to vscode
const VALID_PLATFORMS = ["vscode", "standalone"];
if (!VALID_PLATFORMS.includes(platform)) {
throw new Error(`Invalid PLATFORM "${platform}". Must be one of: ${VALID_PLATFORMS.join(", ")}`)
}
console.log("Building webview for", platform)
export default defineConfig(({ mode }) => {
const env = {
...loadEnv(mode, __dirname, ""),
...process.env,
};
export default defineConfig({
base: "./",
optimizeDeps: {
force: true, // Forces re-optimization
},
plugins: [react(), tailwindcss(), writePortToFile()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/setupTests.ts"],
coverage: {
provider: "v8",
reportOnFailure: true,
reporter: ["html", "lcov", "text"],
reportsDirectory: "./coverage",
exclude: [
"**/*.{spec,test}.{js,jsx,ts,tsx,mjs,cjs}",
const platform = env.PLATFORM || "vscode"; // Default to vscode
"**/*.d.ts",
"**/vite-env.d.ts",
"**/*.{config,setup}.{js,ts,mjs,cjs}",
if (!VALID_PLATFORMS.includes(platform)) {
throw new Error(
`Invalid PLATFORM "${platform}". Must be one of: ${VALID_PLATFORMS.join(", ")}`,
);
}
console.log("Building webview for", platform);
"**/*.{css,scss,sass,less,styl}",
"**/*.{svg,png,jpg,jpeg,gif,ico}",
"**/*.{json,yaml,yml}",
"**/__mocks__/**",
"node_modules/**",
"build/**",
"coverage/**",
"dist/**",
"public/**",
"src/services/grpc-client.ts",
],
return {
base: "./",
optimizeDeps: {
force: true, // Forces re-optimization
},
},
build: {
outDir: "build",
reportCompressedSize: false,
// Only minify in production build
minify: !isDevBuild,
// Enable inline source maps for dev build
sourcemap: isDevBuild ? "inline" : false,
rollupOptions: {
output: {
inlineDynamicImports: true,
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
// Disable compact output for dev build
compact: !isDevBuild,
// Add generous formatting for dev build
...(isDevBuild && {
generatedCode: {
constBindings: false,
objectShorthand: false,
arrowFunctions: false,
},
}),
plugins: [react(), tailwindcss(), writePortToFile()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/setupTests.ts"],
coverage: {
provider: "v8",
reportOnFailure: true,
reporter: ["html", "lcov", "text"],
reportsDirectory: "./coverage",
exclude: [
"**/*.{spec,test}.{js,jsx,ts,tsx,mjs,cjs}",
"**/*.d.ts",
"**/vite-env.d.ts",
"**/*.{config,setup}.{js,ts,mjs,cjs}",
"**/*.{css,scss,sass,less,styl}",
"**/*.{svg,png,jpg,jpeg,gif,ico}",
"**/*.{json,yaml,yml}",
"**/__mocks__/**",
"node_modules/**",
"build/**",
"coverage/**",
"dist/**",
"public/**",
"src/services/grpc-client.ts",
],
},
},
chunkSizeWarningLimit: 100000,
},
server: {
port: 25463,
hmr: {
host: "localhost",
protocol: "ws",
build: {
outDir: "build",
reportCompressedSize: false,
// Only minify in production build
minify: !isDevBuild,
// Enable inline source maps for dev build
sourcemap: isDevBuild ? "inline" : false,
rollupOptions: {
output: {
inlineDynamicImports: true,
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
// Disable compact output for dev build
compact: !isDevBuild,
// Add generous formatting for dev build
...(isDevBuild && {
generatedCode: {
constBindings: false,
objectShorthand: false,
arrowFunctions: false,
},
}),
},
},
chunkSizeWarningLimit: 100000,
},
cors: {
origin: "*",
methods: "*",
allowedHeaders: "*",
server: {
port: 25463,
hmr: {
host: "localhost",
protocol: "ws",
},
cors: {
origin: "*",
methods: "*",
allowedHeaders: "*",
},
},
},
define: {
__PLATFORM__: JSON.stringify(platform),
__NODE_PLATFORM__: JSON.stringify(process.platform),
"process.env.CLINE_ENVIRONMENT": JSON.stringify(process.env.CLINE_ENVIRONMENT ?? "production"),
"process.env.IS_DEV": JSON.stringify(process.env.IS_DEV),
"process.env.IS_TEST": JSON.stringify(process.env.IS_TEST),
"process.env.CI": JSON.stringify(process.env.CI),
// PostHog environment variables
"process.env.TELEMETRY_SERVICE_API_KEY": JSON.stringify(process.env.TELEMETRY_SERVICE_API_KEY),
"process.env.ERROR_SERVICE_API_KEY": JSON.stringify(process.env.ERROR_SERVICE_API_KEY),
"process.env.ENABLE_ERROR_AUTOCAPTURE": JSON.stringify(process.env.ENABLE_ERROR_AUTOCAPTURE),
// OpenTelemetry environment variables
"process.env.OTEL_TELEMETRY_ENABLED": JSON.stringify(process.env.OTEL_TELEMETRY_ENABLED),
"process.env.OTEL_METRICS_EXPORTER": JSON.stringify(process.env.OTEL_METRICS_EXPORTER),
"process.env.OTEL_LOGS_EXPORTER": JSON.stringify(process.env.OTEL_LOGS_EXPORTER),
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_PROTOCOL),
"process.env.OTEL_EXPORTER_OTLP_ENDPOINT": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
"process.env.OTEL_EXPORTER_OTLP_HEADERS": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_HEADERS),
"process.env.OTEL_METRIC_EXPORT_INTERVAL": JSON.stringify(process.env.OTEL_METRIC_EXPORT_INTERVAL),
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
"@components": resolve(__dirname, "./src/components"),
"@context": resolve(__dirname, "./src/context"),
"@shared": resolve(__dirname, "../src/shared"),
"@utils": resolve(__dirname, "./src/utils"),
define: {
__PLATFORM__: JSON.stringify(platform),
__NODE_PLATFORM__: JSON.stringify(process.platform),
"process.env.CLINE_ENVIRONMENT": JSON.stringify(
env.CLINE_ENVIRONMENT ?? "production",
),
"process.env.IS_DEV": JSON.stringify(env.IS_DEV),
"process.env.IS_TEST": JSON.stringify(env.IS_TEST),
"process.env.CI": JSON.stringify(env.CI),
// PostHog environment variables
"process.env.TELEMETRY_SERVICE_API_KEY": JSON.stringify(
env.TELEMETRY_SERVICE_API_KEY,
),
"process.env.ERROR_SERVICE_API_KEY": JSON.stringify(
env.ERROR_SERVICE_API_KEY,
),
"process.env.ENABLE_ERROR_AUTOCAPTURE": JSON.stringify(
env.ENABLE_ERROR_AUTOCAPTURE,
),
// OpenTelemetry environment variables
"process.env.OTEL_TELEMETRY_ENABLED": JSON.stringify(
env.OTEL_TELEMETRY_ENABLED,
),
"process.env.OTEL_METRICS_EXPORTER": JSON.stringify(
env.OTEL_METRICS_EXPORTER,
),
"process.env.OTEL_LOGS_EXPORTER": JSON.stringify(env.OTEL_LOGS_EXPORTER),
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": JSON.stringify(
env.OTEL_EXPORTER_OTLP_PROTOCOL,
),
"process.env.OTEL_EXPORTER_OTLP_ENDPOINT": JSON.stringify(
env.OTEL_EXPORTER_OTLP_ENDPOINT,
),
"process.env.OTEL_EXPORTER_OTLP_HEADERS": JSON.stringify(
env.OTEL_EXPORTER_OTLP_HEADERS,
),
"process.env.OTEL_METRIC_EXPORT_INTERVAL": JSON.stringify(
env.OTEL_METRIC_EXPORT_INTERVAL,
),
},
},
})
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
"@components": resolve(__dirname, "./src/components"),
"@context": resolve(__dirname, "./src/context"),
"@shared": resolve(__dirname, "../src/shared"),
"@utils": resolve(__dirname, "./src/utils"),
},
},
};
});