mirror of
https://github.com/cline/cline.git
synced 2026-09-06 12:28:08 +08:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7761370240 | |||
| f2d2e15291 | |||
| 7a55977265 | |||
| 6d8703c894 | |||
| 1e3b23030c | |||
| 75d06f95e1 | |||
| b86f9279c3 | |||
| 7de9c46ebf | |||
| 3fdc186f18 | |||
| f267bf47e3 | |||
| 5dd9d57e42 | |||
| 9701539dfd | |||
| 34b2f60394 | |||
| e987c00d7c | |||
| c309edd157 | |||
| 8de56b5a96 | |||
| 7ab3d95dc7 | |||
| a62df28da4 | |||
| 00d9a1f925 | |||
| b560f1f381 | |||
| 4d60a8afd8 | |||
| 744a87be5d | |||
| 9ff0573c2e | |||
| a06af825a2 | |||
| 187aa19c00 | |||
| 716172d05c | |||
| fae0c4674b | |||
| 81a69e9ad9 | |||
| e867f4520b | |||
| a2ca96cae1 | |||
| edc088fe5f | |||
| 720cf5ff3f | |||
| 3037b393a8 | |||
| 7eb7897ff2 | |||
| 797565f203 | |||
| 3d312a4bb8 | |||
| aa3f38d1d9 | |||
| 302967f679 | |||
| 8c859c182c | |||
| 83e062e743 | |||
| 264e2c9835 | |||
| 81a6fba11c | |||
| 9da3c59553 | |||
| 0e01b99e76 | |||
| ba5f22f58c | |||
| 1cbfc7197e | |||
| 4d8f9ea1a3 | |||
| 40b0b0c236 | |||
| 8c5e1289bf | |||
| 4cddde58ed | |||
| 9d45accc3b | |||
| 57760c1e20 | |||
| fc33e8dbd9 | |||
| b1dcaf576a | |||
| 419f2cea73 | |||
| 54d8c695a3 | |||
| 5a62ab8564 | |||
| f81afb51b5 |
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- legacy-extension
|
||||
workflow_call:
|
||||
|
||||
# Set default permissions for all jobs
|
||||
|
||||
+110
@@ -1,5 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.12]
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for free Cline models, shown as "(free)" in the model picker, with a dedicated error card that includes the reset time when the free limit is reached.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep Claude Code responses that were already streamed when the CLI exits with a max-turns error, instead of discarding a valid response.
|
||||
|
||||
## [4.0.11]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
|
||||
- Add Moonshot Kimi K3 support.
|
||||
- Include the host plugin version in telemetry events.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
|
||||
- Enable native tool calling for Kimi K3 models, fixing empty responses.
|
||||
|
||||
## [4.0.10]
|
||||
|
||||
### Added
|
||||
|
||||
- Add telemetry to track when Cline reaches the consecutive mistake limit.
|
||||
|
||||
## [4.0.9]
|
||||
|
||||
### Added
|
||||
|
||||
- Add GPT-5.6 ChatGPT subscription models.
|
||||
|
||||
### Changed
|
||||
|
||||
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
|
||||
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
|
||||
|
||||
## [4.0.8]
|
||||
|
||||
### Added
|
||||
|
||||
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
|
||||
|
||||
## [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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,9 @@ const copyWasmFiles = {
|
||||
const buildEnvVars = {
|
||||
"import.meta.url": "_importMetaUrl",
|
||||
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
|
||||
// Always inline these values so ordinary builds cannot be mislabeled by a
|
||||
// user's runtime environment. Only the combined rollout workflow sets them.
|
||||
"process.env.CLINE_ROLLOUT_VARIANT": JSON.stringify(process.env.CLINE_ROLLOUT_VARIANT || ""),
|
||||
}
|
||||
|
||||
if (production) {
|
||||
|
||||
@@ -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.12",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
|
||||
@@ -331,6 +331,10 @@ message ModelsApiOptions {
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
|
||||
optional string act_mode_cline_pass_model_id = 237;
|
||||
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 238;
|
||||
optional bool plan_mode_vertex_custom_model_selected = 239;
|
||||
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 240;
|
||||
optional bool act_mode_vertex_custom_model_selected = 241;
|
||||
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 242;
|
||||
}
|
||||
|
||||
// Request for updating API configuration (legacy - uses combined configuration)
|
||||
@@ -698,4 +702,8 @@ message ModelsApiConfiguration {
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
|
||||
optional string act_mode_cline_pass_model_id = 242;
|
||||
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 243;
|
||||
optional bool plan_mode_vertex_custom_model_selected = 244;
|
||||
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 245;
|
||||
optional bool act_mode_vertex_custom_model_selected = 246;
|
||||
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 247;
|
||||
}
|
||||
|
||||
@@ -287,6 +287,10 @@ message Settings {
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional bool lazy_teammate_mode_enabled = 183;
|
||||
optional bool plan_mode_vertex_custom_model_selected = 184;
|
||||
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 185;
|
||||
optional bool act_mode_vertex_custom_model_selected = 186;
|
||||
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 187;
|
||||
}
|
||||
|
||||
message State {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
@@ -136,6 +138,8 @@ function createHandlerForProvider(
|
||||
vertexProjectId: options.vertexProjectId,
|
||||
vertexRegion: options.vertexRegion,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
vertexCustomModelInfo:
|
||||
mode === "plan" ? options.planModeVertexCustomModelInfo : options.actModeVertexCustomModelInfo,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
geminiApiKey: options.geminiApiKey,
|
||||
@@ -204,6 +208,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 +294,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,
|
||||
|
||||
@@ -64,6 +64,30 @@ describe("AnthropicHandler", () => {
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-7:1m"])
|
||||
})
|
||||
|
||||
it("should return the Opus 5 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-5",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-5")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-5"])
|
||||
})
|
||||
|
||||
it("should return the Opus 5 1m model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-5:1m",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-5:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-5:1m"])
|
||||
})
|
||||
|
||||
it("should return the 4.8 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
|
||||
@@ -215,11 +215,21 @@ describe("AwsBedrockHandler", () => {
|
||||
bedrockModels["anthropic.claude-opus-4-8:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
})
|
||||
|
||||
it("should mark Bedrock Opus 5 variants as global-endpoint capable", () => {
|
||||
bedrockModels["anthropic.claude-opus-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
bedrockModels["anthropic.claude-opus-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
})
|
||||
|
||||
it("should mark Bedrock Fable 5 variants as global-endpoint capable", () => {
|
||||
bedrockModels["anthropic.claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
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)
|
||||
@@ -234,6 +244,13 @@ describe("AwsBedrockHandler", () => {
|
||||
vertexGlobalModels.should.have.property("claude-opus-4-8:1m")
|
||||
})
|
||||
|
||||
it("should include Vertex Opus 5 variants in the derived global model list", () => {
|
||||
vertexModels["claude-opus-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexModels["claude-opus-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexGlobalModels.should.have.property("claude-opus-5")
|
||||
vertexGlobalModels.should.have.property("claude-opus-5:1m")
|
||||
})
|
||||
|
||||
it("should include Vertex Fable 5 variants in the derived global model list", () => {
|
||||
vertexModels["claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexModels["claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
@@ -990,6 +1007,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,
|
||||
|
||||
@@ -396,6 +396,26 @@ describe("ClaudeCodeHandler", () => {
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 5 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-5",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-5")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Opus 5 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-5[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-5[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.8 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-8",
|
||||
@@ -426,6 +446,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,8 +6,10 @@ import { MoonshotHandler } from "../moonshot"
|
||||
|
||||
interface MoonshotRequestPayload {
|
||||
model: string
|
||||
temperature: number
|
||||
max_tokens: number
|
||||
messages?: Array<{ role: string; reasoning_content?: string }>
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
max_completion_tokens?: number
|
||||
}
|
||||
|
||||
describe("MoonshotHandler", () => {
|
||||
@@ -47,7 +49,66 @@ describe("MoonshotHandler", () => {
|
||||
|
||||
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
|
||||
payload.model.should.equal("kimi-k2.6")
|
||||
payload.temperature.should.equal(moonshotModels["kimi-k2.6"].temperature)
|
||||
payload.max_tokens.should.equal(moonshotModels["kimi-k2.6"].maxTokens)
|
||||
should(payload.temperature).equal(moonshotModels["kimi-k2.6"].temperature)
|
||||
should(payload.max_tokens).equal(moonshotModels["kimi-k2.6"].maxTokens)
|
||||
})
|
||||
|
||||
it("supports kimi-k3 request requirements and scopes reasoning replay", async () => {
|
||||
const handler = new MoonshotHandler({
|
||||
moonshotApiKey: "test-api-key",
|
||||
apiModelId: "kimi-k3",
|
||||
})
|
||||
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const messages: ClineStorageMessage[] = [
|
||||
{ role: "user", content: "foreign-provider question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Foreign reasoning", signature: "" },
|
||||
{ type: "text", text: "Foreign answer" },
|
||||
],
|
||||
modelInfo: { providerId: "anthropic", modelId: "claude-sonnet-4", mode: "act" },
|
||||
},
|
||||
{ role: "user", content: "K3 question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "K3 reasoning", signature: "" },
|
||||
{ type: "text", text: "K3 answer" },
|
||||
],
|
||||
modelInfo: { providerId: "moonshot", modelId: "kimi-k3", mode: "act" },
|
||||
},
|
||||
{ role: "user", content: "legacy question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Unattributed reasoning", signature: "" },
|
||||
{ type: "text", text: "Legacy answer" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "follow-up question" },
|
||||
]
|
||||
for await (const _chunk of handler.createMessage("system", messages)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
|
||||
payload.model.should.equal("kimi-k3")
|
||||
should(payload.max_completion_tokens).equal(moonshotModels["kimi-k3"].maxTokens)
|
||||
should(payload.max_tokens).equal(undefined)
|
||||
should(payload.temperature).equal(undefined)
|
||||
const assistantReasoning = payload.messages
|
||||
?.filter((message) => message.role === "assistant")
|
||||
.map((message) => message.reasoning_content)
|
||||
should(assistantReasoning).deepEqual([undefined, "K3 reasoning", undefined])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import "should"
|
||||
import type OpenAI from "openai"
|
||||
import sinon from "sinon"
|
||||
import type { ApiStreamChunk } from "../../transform/stream"
|
||||
import { OpenAiHandler } from "../openai"
|
||||
|
||||
describe("OpenAiHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: unknown[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
const createHandler = (chunks: unknown[]) => {
|
||||
const handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-api-key",
|
||||
openAiModelId: "test-model",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(createAsyncIterable(chunks)),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as unknown as { ensureClient: () => OpenAI }, "ensureClient").returns(fakeClient as unknown as OpenAI)
|
||||
return handler
|
||||
}
|
||||
|
||||
it("should emit the latest cumulative usage snapshot only once", async () => {
|
||||
const handler = createHandler([
|
||||
{
|
||||
choices: [{ delta: { content: "O" } }],
|
||||
usage: {
|
||||
prompt_tokens: 48,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 49,
|
||||
prompt_tokens_details: { cached_tokens: 32 },
|
||||
},
|
||||
},
|
||||
{
|
||||
choices: [{ delta: { content: "K" } }],
|
||||
usage: {
|
||||
prompt_tokens: 48,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 50,
|
||||
prompt_tokens_details: { cached_tokens: 32 },
|
||||
},
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
|
||||
])
|
||||
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{ type: "text", text: "O" },
|
||||
{ type: "text", text: "K" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 48,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 32,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should preserve providers that emit usage only on the final chunk", async () => {
|
||||
const handler = createHandler([
|
||||
{ choices: [{ delta: { content: "OK" } }] },
|
||||
{
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 17,
|
||||
completion_tokens: 9,
|
||||
total_tokens: 26,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{ type: "text", text: "OK" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 17,
|
||||
outputTokens: 9,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { buildClineExtraHeaders } from "@/services/EnvUtils"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
|
||||
import { isClineFreeModelId } from "@/shared/cline/free-models"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
@@ -42,6 +43,10 @@ const CLINE_FREE_MODEL_IDS = new Set([
|
||||
...Object.keys(clinePassModels).map((modelId) => normalizeModelId(modelId)),
|
||||
])
|
||||
|
||||
function isFreeClineModel(modelId: string, freeModelIds: Set<string>): boolean {
|
||||
return isClineFreeModelId(modelId) || freeModelIds.has(normalizeModelId(modelId))
|
||||
}
|
||||
|
||||
function getCacheReadTokens(usage: any): number {
|
||||
return usage?.prompt_tokens_details?.cached_tokens || usage?.cache_read_input_tokens || 0
|
||||
}
|
||||
@@ -242,7 +247,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-expect-error-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = freeModelIds.has(normalizeModelId(modelId))
|
||||
const isFreeModel = isFreeClineModel(modelId, freeModelIds)
|
||||
const cacheReadTokens = getCacheReadTokens(chunk.usage)
|
||||
const cacheWriteTokens = getCacheWriteTokens(chunk.usage)
|
||||
|
||||
@@ -299,7 +304,7 @@ export class ClineHandler implements ApiHandler {
|
||||
const generation = response.data
|
||||
let totalCost = generation?.total_cost || 0
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = resolvedFreeModelIds.has(normalizeModelId(modelId))
|
||||
const isFreeModel = isFreeClineModel(modelId, resolvedFreeModelIds)
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FunctionDeclaration as GoogleTool,
|
||||
ThinkingLevel,
|
||||
} from "@google/genai"
|
||||
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
|
||||
import { GeminiModelId, geminiDefaultModelId, geminiModels, getVertexCustomModelInfo, ModelInfo } from "@shared/api"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
@@ -25,6 +25,7 @@ interface GeminiHandlerOptions extends CommonApiHandlerOptions {
|
||||
isVertex?: boolean
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
vertexCustomModelInfo?: ModelInfo
|
||||
geminiApiKey?: string
|
||||
geminiBaseUrl?: string
|
||||
thinkingBudgetTokens?: number
|
||||
@@ -464,12 +465,15 @@ export class GeminiHandler implements ApiHandler {
|
||||
/**
|
||||
* Get the model ID and info for the current configuration
|
||||
*/
|
||||
getModel(): { id: GeminiModelId; info: ModelInfo } {
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in geminiModels) {
|
||||
const id = modelId as GeminiModelId
|
||||
return { id, info: geminiModels[id] }
|
||||
}
|
||||
if (modelId && this.options.isVertex) {
|
||||
return { id: modelId, info: getVertexCustomModelInfo(this.options.vertexCustomModelInfo) }
|
||||
}
|
||||
return {
|
||||
id: geminiDefaultModelId,
|
||||
info: geminiModels[geminiDefaultModelId],
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { addReasoningContent } from "../transform/r1-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
@@ -48,16 +49,25 @@ export class MoonshotHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
...(model.id === "kimi-k3"
|
||||
? addReasoningContent(convertedMessages, messages, {
|
||||
includePreviousTurns: true,
|
||||
shouldIncludeReasoning: (message) =>
|
||||
message.modelInfo?.providerId === "moonshot" && message.modelInfo.modelId === "kimi-k3",
|
||||
})
|
||||
: convertedMessages),
|
||||
]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: openAiMessages,
|
||||
temperature: model.info.temperature,
|
||||
max_tokens: model.info.maxTokens,
|
||||
// Kimi K3 fixes its sampling parameters and uses max_completion_tokens.
|
||||
...(model.id === "kimi-k3"
|
||||
? { max_completion_tokens: model.info.maxTokens }
|
||||
: { max_tokens: model.info.maxTokens, temperature: model.info.temperature }),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...getOpenAIToolParams(tools),
|
||||
|
||||
@@ -145,6 +145,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
let latestUsage: OpenAI.Completions.CompletionUsage | undefined
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
@@ -167,14 +168,18 @@ export class OpenAiHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
latestUsage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
if (latestUsage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: latestUsage.prompt_tokens || 0,
|
||||
outputTokens: latestUsage.completion_tokens || 0,
|
||||
cacheReadTokens: latestUsage.prompt_tokens_details?.cached_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheWriteTokens: latestUsage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
|
||||
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
|
||||
import { FunctionDeclaration as GoogleTool } from "@google/genai"
|
||||
import { CLAUDE_SONNET_1M_SUFFIX, ModelInfo, VertexModelId, vertexDefaultModelId, vertexModels } from "@shared/api"
|
||||
import { CLAUDE_SONNET_1M_SUFFIX, getVertexCustomModelInfo, ModelInfo, vertexDefaultModelId, vertexModels } from "@shared/api"
|
||||
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
@@ -16,6 +16,7 @@ interface VertexHandlerOptions extends CommonApiHandlerOptions {
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
apiModelId?: string
|
||||
vertexCustomModelInfo?: ModelInfo
|
||||
thinkingBudgetTokens?: number
|
||||
geminiApiKey?: string
|
||||
geminiBaseUrl?: string
|
||||
@@ -267,12 +268,15 @@ export class VertexHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: VertexModelId; info: ModelInfo } {
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in vertexModels) {
|
||||
const id = modelId as VertexModelId
|
||||
const id = modelId as keyof typeof vertexModels
|
||||
return { id, info: vertexModels[id] }
|
||||
}
|
||||
if (modelId) {
|
||||
return { id: modelId, info: getVertexCustomModelInfo(this.options.vertexCustomModelInfo) }
|
||||
}
|
||||
return {
|
||||
id: vertexDefaultModelId,
|
||||
info: vertexModels[vertexDefaultModelId],
|
||||
|
||||
@@ -26,6 +26,26 @@ export class RetriableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const INFERENCE_CAP_ERROR_CODE = "INFERENCE_CAP_ERROR"
|
||||
|
||||
/**
|
||||
* Inference cap errors mean the user is out of quota until it resets, so retrying
|
||||
* only burns backoff. Providers surface the code in a few shapes: directly on the
|
||||
* error, nested under `error`/`details`, or embedded in a wrapped message string
|
||||
* (see ClineHandler, which rethrows stream errors as `Cline API Error <code>: ...`).
|
||||
*/
|
||||
function isInferenceCapError(error: any): boolean {
|
||||
if (
|
||||
error?.code === INFERENCE_CAP_ERROR_CODE ||
|
||||
error?.error?.code === INFERENCE_CAP_ERROR_CODE ||
|
||||
error?.details?.code === INFERENCE_CAP_ERROR_CODE
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return typeof error?.message === "string" && error.message.includes(INFERENCE_CAP_ERROR_CODE)
|
||||
}
|
||||
|
||||
export function withRetry(options: RetryOptions = {}) {
|
||||
const { maxRetries, baseDelay, maxDelay, retryAllErrors } = { ...DEFAULT_OPTIONS, ...options }
|
||||
|
||||
@@ -40,8 +60,9 @@ export function withRetry(options: RetryOptions = {}) {
|
||||
} catch (error: any) {
|
||||
const isRateLimit = error?.status === 429 || error instanceof RetriableError
|
||||
const isLastAttempt = attempt === maxRetries - 1
|
||||
|
||||
if ((!isRateLimit && !retryAllErrors) || isLastAttempt) {
|
||||
// We shouldn't retry cap limits because they mean the user needs to wait for longer
|
||||
const isCapLimitError = isInferenceCapError(error)
|
||||
if ((!isRateLimit && !retryAllErrors) || isLastAttempt || isCapLimitError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
ModelInfo,
|
||||
OPENROUTER_PROVIDER_PREFERENCES,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
@@ -62,9 +64,11 @@ 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 ||
|
||||
model.id === openRouterClaudeOpus51mModelId ||
|
||||
model.id === openRouterClaudeFable51mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id openrouter API expects
|
||||
@@ -147,6 +151,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":
|
||||
|
||||
@@ -14,14 +14,20 @@ export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
|
||||
* Per DeepSeek API: reasoning_content should be passed back during tool calling in the same turn,
|
||||
* and omitted when starting a new turn.
|
||||
* Adds reasoning_content to OpenAI-compatible messages.
|
||||
* DeepSeek only needs it during tool calling in the current turn. Providers such as Moonshot K3
|
||||
* can include previous turns and filter reasoning by its originating message.
|
||||
*/
|
||||
export function addReasoningContent(
|
||||
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
originalMessages: ClineStorageMessage[],
|
||||
options: {
|
||||
includePreviousTurns?: boolean;
|
||||
shouldIncludeReasoning?: (message: ClineStorageMessage) => boolean;
|
||||
} = {},
|
||||
): DeepSeekReasonerMessage[] {
|
||||
const { includePreviousTurns = false, shouldIncludeReasoning = () => true } = options;
|
||||
|
||||
// Find last user message index (start of current turn)
|
||||
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
|
||||
// so reasoning_content will be added to all assistant messages. This is intentional.
|
||||
@@ -38,7 +44,7 @@ export function addReasoningContent(
|
||||
let assistantIdx = 0;
|
||||
for (const msg of originalMessages) {
|
||||
if (msg.role === "assistant") {
|
||||
if (Array.isArray(msg.content)) {
|
||||
if (Array.isArray(msg.content) && shouldIncludeReasoning(msg)) {
|
||||
const thinking = msg.content
|
||||
.filter(
|
||||
(p): p is ClineAssistantThinkingBlock => p.type === "thinking",
|
||||
@@ -53,12 +59,12 @@ export function addReasoningContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Add reasoning_content only to assistant messages in current turn
|
||||
// Add reasoning_content to assistant messages in the requested scope.
|
||||
let aiIdx = 0;
|
||||
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
|
||||
if (msg.role === "assistant") {
|
||||
const thinking = thinkingByIndex.get(aiIdx++);
|
||||
if (thinking && i >= lastUserIndex) {
|
||||
if (thinking && (includePreviousTurns || i >= lastUserIndex)) {
|
||||
return { ...msg, reasoning_content: thinking };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
CLAUDE_SONNET_1M_SUFFIX,
|
||||
ModelInfo,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
@@ -38,9 +40,11 @@ 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 ||
|
||||
model.id === openRouterClaudeOpus51mModelId ||
|
||||
model.id === openRouterClaudeFable51mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id the API expects
|
||||
|
||||
+140
-1
@@ -1,5 +1,6 @@
|
||||
import { APIError } from "@anthropic-ai/sdk"
|
||||
import { expect } from "chai"
|
||||
import { checkContextWindowExceededError } from "../context-error-handling"
|
||||
import { checkContextWindowExceededError, checkIsAnthropicContextWindowError } from "../context-error-handling"
|
||||
|
||||
describe("checkContextWindowExceededError", () => {
|
||||
it("detects OpenRouter context errors using structured status", () => {
|
||||
@@ -28,4 +29,142 @@ describe("checkContextWindowExceededError", () => {
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(false)
|
||||
})
|
||||
|
||||
it("classifies a real Anthropic overflow error as a context window failure", () => {
|
||||
expect(
|
||||
checkContextWindowExceededError(makeAnthropicError("prompt is too long: 213462 tokens > 200000 maximum")),
|
||||
).to.equal(true)
|
||||
})
|
||||
|
||||
it("does not classify an unrelated Anthropic invalid_request_error as a context window failure", () => {
|
||||
const error = makeAnthropicError("tools.0.custom.input_schema: Extra inputs are not permitted")
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Builds the shape an Anthropic API rejection has by the time it reaches the detector: the SDK's
|
||||
* `APIError` carries the parsed response body on `error`, so the provider's own error object is
|
||||
* nested at `error.error`.
|
||||
*/
|
||||
function makeAnthropicError(message: string, type = "invalid_request_error", status = 400) {
|
||||
return {
|
||||
status,
|
||||
message: `${status} ${JSON.stringify({ type: "error", error: { type, message } })}`,
|
||||
error: {
|
||||
type: "error",
|
||||
error: { type, message },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("checkIsAnthropicContextWindowError", () => {
|
||||
describe("real overflow rejections still auto-truncate", () => {
|
||||
// Anthropic's current overflow message, emitted when the rendered prompt alone exceeds the
|
||||
// model's context window.
|
||||
it("matches 'prompt is too long' with the token counts", () => {
|
||||
expect(
|
||||
checkIsAnthropicContextWindowError(makeAnthropicError("prompt is too long: 213462 tokens > 200000 maximum")),
|
||||
).to.equal(true)
|
||||
})
|
||||
|
||||
// Emitted when the prompt fits but prompt + max_tokens does not.
|
||||
it("matches 'input length and max_tokens exceed context limit'", () => {
|
||||
const error = makeAnthropicError(
|
||||
"input length and max_tokens exceed context limit: 197000 + 8192 > 200000, decrease input length or max_tokens and try again",
|
||||
)
|
||||
|
||||
expect(checkIsAnthropicContextWindowError(error)).to.equal(true)
|
||||
})
|
||||
|
||||
it("matches the older 'input is too long' phrasing", () => {
|
||||
expect(checkIsAnthropicContextWindowError(makeAnthropicError("input is too long for requested model"))).to.equal(true)
|
||||
})
|
||||
|
||||
it("matches when only the SDK's JSON-stringified message carries the overflow text", () => {
|
||||
const error = makeAnthropicError("prompt is too long: 213462 tokens > 200000 maximum")
|
||||
// Some wrappers hand us the error object without the provider message on the inner error.
|
||||
delete (error.error.error as { message?: string }).message
|
||||
|
||||
expect(checkIsAnthropicContextWindowError(error)).to.equal(true)
|
||||
})
|
||||
|
||||
it("matches an error constructed by the Anthropic SDK itself", () => {
|
||||
const body = {
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message: "prompt is too long: 213462 tokens > 200000 maximum" },
|
||||
}
|
||||
const error = APIError.generate(400, body, undefined, new Headers())
|
||||
|
||||
expect(checkIsAnthropicContextWindowError(error)).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("unrelated invalid_request_error rejections are not overflows", () => {
|
||||
// Each of these previously matched on the error type alone, triggering a conversation
|
||||
// truncation and a retry that then failed again for the original reason.
|
||||
const UNRELATED_MESSAGES = [
|
||||
"tools.0.custom.input_schema: Extra inputs are not permitted",
|
||||
"messages.1.content.0.image.source.base64: image exceeds 5 MB maximum: 6291456 bytes > 5242880 bytes",
|
||||
"model: claude-not-a-real-model",
|
||||
"max_tokens: 100000 > 64000, which is the maximum allowed number of output tokens for claude-sonnet-4-5",
|
||||
"messages: at least one message is required",
|
||||
"messages.0: all messages must have non-empty content except for the optional final assistant message",
|
||||
"temperature: Input should be less than or equal to 1",
|
||||
]
|
||||
|
||||
for (const message of UNRELATED_MESSAGES) {
|
||||
it(`does not match: ${message}`, () => {
|
||||
expect(checkIsAnthropicContextWindowError(makeAnthropicError(message))).to.equal(false)
|
||||
})
|
||||
}
|
||||
|
||||
it("does not match an invalid_request_error with no message at all", () => {
|
||||
expect(
|
||||
checkIsAnthropicContextWindowError({ error: { type: "error", error: { type: "invalid_request_error" } } }),
|
||||
).to.equal(false)
|
||||
})
|
||||
|
||||
it("does not match an invalid_request_error with a non-string message", () => {
|
||||
const error = makeAnthropicError("unused")
|
||||
;(error.error.error as { message?: unknown }).message = { detail: "structured" }
|
||||
error.message = ""
|
||||
|
||||
expect(checkIsAnthropicContextWindowError(error)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-invalid_request_error inputs", () => {
|
||||
it("does not match other Anthropic error types even with overflow-shaped text", () => {
|
||||
const error = makeAnthropicError("prompt is too long: 213462 tokens > 200000 maximum", "rate_limit_error", 429)
|
||||
|
||||
expect(checkIsAnthropicContextWindowError(error)).to.equal(false)
|
||||
})
|
||||
|
||||
it("does not match an overloaded_error", () => {
|
||||
expect(checkIsAnthropicContextWindowError(makeAnthropicError("Overloaded", "overloaded_error", 529))).to.equal(false)
|
||||
})
|
||||
|
||||
for (const [label, value] of [
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
["a plain string", "prompt is too long: 213462 tokens > 200000 maximum"],
|
||||
["an object with no error property", { message: "prompt is too long: 213462 tokens > 200000 maximum" }],
|
||||
] as const) {
|
||||
it(`does not match ${label}`, () => {
|
||||
expect(checkIsAnthropicContextWindowError(value)).to.equal(false)
|
||||
})
|
||||
}
|
||||
|
||||
it("does not throw when property access throws", () => {
|
||||
const error = {
|
||||
get error(): never {
|
||||
throw new Error("boom")
|
||||
},
|
||||
}
|
||||
|
||||
expect(checkIsAnthropicContextWindowError(error)).to.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,9 +58,29 @@ function checkIsOpenAIContextWindowError(error: unknown): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
try {
|
||||
return response?.error?.error?.type === "invalid_request_error"
|
||||
// Anthropic returns `invalid_request_error` for many conditions that have nothing to do with
|
||||
// context size (malformed params, invalid tool schema, oversized image, unknown model id), so
|
||||
// the error type alone cannot identify an overflow — it needs a context-specific message too.
|
||||
if (response?.error?.error?.type !== "invalid_request_error") {
|
||||
return false
|
||||
}
|
||||
|
||||
// The Anthropic SDK puts the response body on `error` and, when that body has no top-level
|
||||
// `message`, JSON-stringifies it into the APIError's own `message` — so either path can carry
|
||||
// the overflow text depending on how the error reached us.
|
||||
const messages: unknown[] = [response?.error?.error?.message, response?.message].filter((msg) => msg != null)
|
||||
|
||||
// Anthropic-authored overflow wire messages, matching the patterns the Vercel and Bedrock
|
||||
// branches in this file already use for the same provider messages.
|
||||
const CONTEXT_ERROR_PATTERNS = [
|
||||
/prompt is too long.*tokens?\s*>\s*\d+\s*maximum/i,
|
||||
/input is too long/i,
|
||||
/input length and max_tokens exceed context limit/i,
|
||||
] as const
|
||||
|
||||
return messages.some((msg) => CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(String(msg))))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as yaml from "js-yaml"
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
export type FrontmatterParseResult = {
|
||||
data: Record<string, unknown>
|
||||
data: Record<string, unknown>;
|
||||
/**
|
||||
* The markdown content after stripping the `--- frontmatter ---` block.
|
||||
*
|
||||
* Named `body` (rather than `content`) to make it clear this is the remaining
|
||||
* document body and to keep this helper generic for multiple consumers.
|
||||
*/
|
||||
body: string
|
||||
body: string;
|
||||
|
||||
/**
|
||||
* True when the input contained a frontmatter block, even if parsing failed.
|
||||
@@ -17,15 +17,15 @@ export type FrontmatterParseResult = {
|
||||
* - "no frontmatter provided" (baseline behavior), vs
|
||||
* - "frontmatter was provided" (may have semantic meaning in future consumers).
|
||||
*/
|
||||
hadFrontmatter: boolean
|
||||
hadFrontmatter: boolean;
|
||||
/**
|
||||
* Present only when YAML frontmatter was detected but failed to parse.
|
||||
*
|
||||
* This helper is intentionally fail-open and does not log. Returning `parseError`
|
||||
* lets each caller decide whether to log, surface diagnostics, etc.
|
||||
*/
|
||||
parseError?: string
|
||||
}
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content.
|
||||
@@ -35,19 +35,32 @@ export type FrontmatterParseResult = {
|
||||
* - If no frontmatter exists, returns data={} and body=original markdown.
|
||||
*/
|
||||
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
const match = markdown.match(frontmatterRegex)
|
||||
if (markdown.charCodeAt(0) === 0xfeff) {
|
||||
markdown = markdown.slice(1); // Remove BOM format if present
|
||||
}
|
||||
|
||||
const frontmatterRegex = /^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
const match = markdown.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { data: {}, body: markdown, hadFrontmatter: false }
|
||||
return { data: {}, body: markdown, hadFrontmatter: false };
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
const [, yamlContent, body] = match;
|
||||
try {
|
||||
const data = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>) || {}
|
||||
return { data, body, hadFrontmatter: true }
|
||||
const data =
|
||||
(yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<
|
||||
string,
|
||||
unknown
|
||||
>) || {};
|
||||
return { data, body, hadFrontmatter: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
data: {},
|
||||
body: markdown,
|
||||
hadFrontmatter: true,
|
||||
parseError: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -34,6 +35,7 @@ import { BannerService } from "@/services/banner/BannerService"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { getExtensionVariant } from "@/services/telemetry/rollout-metadata"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
@@ -523,8 +525,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 +857,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")
|
||||
@@ -928,6 +929,7 @@ export class Controller {
|
||||
|
||||
return {
|
||||
version,
|
||||
extensionVariant: getExtensionVariant(),
|
||||
apiConfiguration,
|
||||
currentTaskItem,
|
||||
clineMessages,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as disk from "@core/storage/disk"
|
||||
import { openRouterClaudeFable51mModelId } from "@shared/api"
|
||||
import { openRouterClaudeFable51mModelId, openRouterClaudeOpus51mModelId } from "@shared/api"
|
||||
import axios from "axios"
|
||||
import { expect } from "chai"
|
||||
import fs from "fs/promises"
|
||||
@@ -135,4 +135,131 @@ describe("refreshClineModels", () => {
|
||||
expect(fable1m.contextWindow).to.equal(1_000_000)
|
||||
expect(fable1m.tiers).to.not.equal(undefined)
|
||||
})
|
||||
|
||||
it("adds Claude Opus 5 context variants to the Cline model list", 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: "anthropic/claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
description: "Fetched description",
|
||||
context_length: 1_000_000,
|
||||
top_provider: {
|
||||
max_completion_tokens: 128_000,
|
||||
context_length: 1_000_000,
|
||||
is_moderated: false,
|
||||
},
|
||||
architecture: {
|
||||
modality: ["text", "image"],
|
||||
},
|
||||
pricing: {
|
||||
prompt: "0.000005",
|
||||
completion: "0.000025",
|
||||
input_cache_read: "0.0000005",
|
||||
input_cache_write: "0.00000625",
|
||||
},
|
||||
supported_parameters: ["include_reasoning", "reasoning"],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const models = await refreshClineModels({} as Controller)
|
||||
const opus5 = models["anthropic/claude-opus-5"]
|
||||
const opus51m = models[openRouterClaudeOpus51mModelId]
|
||||
|
||||
expect(opus5.contextWindow).to.equal(200_000)
|
||||
expect(opus5.maxTokens).to.equal(128_000)
|
||||
expect(opus5.supportsPromptCache).to.equal(true)
|
||||
expect(opus5.inputPrice).to.equal(5)
|
||||
expect(opus5.outputPrice).to.equal(25)
|
||||
expect(opus5.cacheWritesPrice).to.equal(6.25)
|
||||
expect(opus5.cacheReadsPrice).to.equal(0.5)
|
||||
expect(opus51m.contextWindow).to.equal(1_000_000)
|
||||
expect(opus51m.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)
|
||||
})
|
||||
})
|
||||
|
||||
+98
-3
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
CLAUDE_OPUS_1M_TIERS,
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@/shared/api"
|
||||
import { formatClineFreeModelName, isClineFreeModelId, zeroPricedModelInfo } from "@/shared/cline/free-models"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -87,6 +90,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 +141,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 +215,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":
|
||||
@@ -198,6 +241,15 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
break
|
||||
case "anthropic/claude-opus-5":
|
||||
case "anthropic/claude-5-opus":
|
||||
modelInfo.contextWindow = 200_000
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.inputPrice = 5.0
|
||||
modelInfo.outputPrice = 25.0
|
||||
modelInfo.cacheWritesPrice = 6.25
|
||||
modelInfo.cacheReadsPrice = 0.5
|
||||
break
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-opus-4.7":
|
||||
case "anthropic/claude-opus-4.8":
|
||||
@@ -266,6 +318,16 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
)
|
||||
}
|
||||
|
||||
// cline-free/ models are promotional: they always bill at $0 and are marked
|
||||
// "(free)" so users can tell them apart from the paid model of the same slug.
|
||||
if (isClineFreeModelId(rawModel.id)) {
|
||||
models[rawModel.id] = zeroPricedModelInfo({
|
||||
...modelInfo,
|
||||
name: formatClineFreeModelName(rawModel.id, modelInfo.name),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
|
||||
// Add custom :1m model variant for Sonnet models
|
||||
@@ -273,12 +335,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
|
||||
}
|
||||
@@ -309,6 +376,12 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
|
||||
}
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-opus-5" || rawModel.id === "anthropic/claude-5-opus") {
|
||||
const claudeOpus51mModelInfo = cloneDeep(modelInfo)
|
||||
claudeOpus51mModelInfo.contextWindow = 1_000_000
|
||||
claudeOpus51mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
|
||||
models[openRouterClaudeOpus51mModelId] = claudeOpus51mModelInfo
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-fable-5") {
|
||||
const claudeFable1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeFable1mModelInfo.contextWindow = 1_000_000
|
||||
@@ -319,8 +392,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 +414,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
CLAUDE_OPUS_1M_TIERS,
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@/shared/api"
|
||||
@@ -150,6 +152,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":
|
||||
@@ -173,6 +185,15 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
break
|
||||
case "anthropic/claude-opus-5":
|
||||
case "anthropic/claude-5-opus":
|
||||
modelInfo.contextWindow = 200_000 // restrict to 200k, 1m variant created below
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.inputPrice = 5.0
|
||||
modelInfo.outputPrice = 25.0
|
||||
modelInfo.cacheWritesPrice = 6.25
|
||||
modelInfo.cacheReadsPrice = 0.5
|
||||
break
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-opus-4.7":
|
||||
case "anthropic/claude-opus-4.8":
|
||||
@@ -294,11 +315,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
|
||||
@@ -332,6 +359,12 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
|
||||
}
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-opus-5" || rawModel.id === "anthropic/claude-5-opus") {
|
||||
const claudeOpus51mModelInfo = cloneDeep(modelInfo)
|
||||
claudeOpus51mModelInfo.contextWindow = 1_000_000
|
||||
claudeOpus51mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
|
||||
models[openRouterClaudeOpus51mModelId] = claudeOpus51mModelInfo
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-fable-5") {
|
||||
const claudeFable1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeFable1mModelInfo.contextWindow = 1_000_000
|
||||
|
||||
@@ -109,6 +109,12 @@ export async function updateApiConfigurationProto(
|
||||
protoApiConfiguration.planModeAihubmixModelInfo,
|
||||
)
|
||||
: undefined,
|
||||
planModeVertexCustomModelInfo:
|
||||
protoApiConfiguration.planModeVertexCustomModelInfo
|
||||
? fromProtobufModelInfo(
|
||||
protoApiConfiguration.planModeVertexCustomModelInfo,
|
||||
)
|
||||
: undefined,
|
||||
|
||||
// Act Mode
|
||||
actModeOpenRouterModelInfo:
|
||||
@@ -168,6 +174,12 @@ export async function updateApiConfigurationProto(
|
||||
protoApiConfiguration.actModeAihubmixModelInfo,
|
||||
)
|
||||
: undefined,
|
||||
actModeVertexCustomModelInfo:
|
||||
protoApiConfiguration.actModeVertexCustomModelInfo
|
||||
? fromProtobufModelInfo(
|
||||
protoApiConfiguration.actModeVertexCustomModelInfo,
|
||||
)
|
||||
: undefined,
|
||||
geminiPlanModeThinkingLevel:
|
||||
protoApiConfiguration.geminiPlanModeThinkingLevel,
|
||||
geminiActModeThinkingLevel:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,22 @@ 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,
|
||||
);
|
||||
// Daily Cline free-model limits reset in hours — same reasoning as
|
||||
// ClinePass limits: surface the actionable UI instead of retrying.
|
||||
const isClineFreeModelLimitError = clineError.isErrorType(
|
||||
ClineErrorType.ClineFreeModelLimit,
|
||||
);
|
||||
// A retired free model can never answer — its id was removed from
|
||||
// the catalog when the promotion ended — so retrying only delays
|
||||
// the card that routes the user into the model picker.
|
||||
const isClineFreePromotionEndedError = clineError.isErrorType(
|
||||
ClineErrorType.ClineFreePromotionEnded,
|
||||
);
|
||||
|
||||
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
|
||||
const isClineProviderInsufficientCredits = (() => {
|
||||
@@ -2499,7 +2510,47 @@ export class Task {
|
||||
!quotaExceeded &&
|
||||
!isEntitlementError &&
|
||||
!isOrgClinePassRestrictionError &&
|
||||
!isClinePassLimitError &&
|
||||
!isClineFreeModelLimitError &&
|
||||
!isClineFreePromotionEndedError &&
|
||||
this.taskState.autoRetryAttempts < 3;
|
||||
|
||||
// Mirror the SDK extension's provider-failure reporting: same
|
||||
// errorType/failurePhase schema so error rates are comparable
|
||||
// across the A/B rollout cohorts. Only failures that actually
|
||||
// surface to the user are reported — attempts an auto-retry
|
||||
// absorbs emit nothing, matching the SDK extension, whose
|
||||
// provider layer retries transients silently before any event
|
||||
// exists. Truncation-recovered context-window overruns are
|
||||
// likewise unreported: they take the branch above and never
|
||||
// reach here, so reaching this point with an overflow means the
|
||||
// overrun survived a truncation and is surfacing to the user.
|
||||
// It is reported and tagged (see errorClass) rather than
|
||||
// dropped, so it stays countable without inflating the
|
||||
// cohort error rate — dashboards comparing cohorts exclude
|
||||
// error_class = 'context_window_exceeded'.
|
||||
// (`abort` gate: a user cancel unwinds through this catch as a
|
||||
// generic "Cline instance aborted" error — never a provider
|
||||
// failure, and the SDK extension doesn't report cancels either.)
|
||||
if (!shouldRetry && !this.taskState.abort) {
|
||||
telemetryService.captureProviderApiError({
|
||||
ulid: this.ulid,
|
||||
model: model.id,
|
||||
provider: providerId,
|
||||
errorMessage: clineError.message,
|
||||
errorStatus: clineError._error?.status,
|
||||
requestId: clineError._error?.request_id,
|
||||
errorType: ClineError.getErrorType(clineError),
|
||||
failurePhase: "streaming",
|
||||
// A failure awaiting the provider's first chunk is
|
||||
// definitionally a provider failure, so it is always
|
||||
// classified.
|
||||
errorClass: isContextWindowExceededError
|
||||
? "context_window_exceeded"
|
||||
: "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRetry) {
|
||||
// Auto-retry enabled with max 3 attempts: automatically approve the retry
|
||||
this.taskState.autoRetryAttempts++;
|
||||
@@ -2562,7 +2613,10 @@ export class Task {
|
||||
!isSpendLimitError &&
|
||||
!quotaExceeded &&
|
||||
!isEntitlementError &&
|
||||
!isOrgClinePassRestrictionError;
|
||||
!isOrgClinePassRestrictionError &&
|
||||
!isClinePassLimitError &&
|
||||
!isClineFreeModelLimitError &&
|
||||
!isClineFreePromotionEndedError;
|
||||
if (showRetry) {
|
||||
await this.say(
|
||||
"error_retry",
|
||||
@@ -2825,6 +2879,16 @@ export class Task {
|
||||
this.taskState.consecutiveMistakeCount >=
|
||||
this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")
|
||||
) {
|
||||
telemetryService.captureMistakeLimitReached({
|
||||
ulid: this.ulid,
|
||||
model: model.id,
|
||||
provider: providerId,
|
||||
consecutiveMistakes: this.taskState.consecutiveMistakeCount,
|
||||
maxConsecutiveMistakes: this.stateManager.getGlobalSettingsKey(
|
||||
"maxConsecutiveMistakes",
|
||||
),
|
||||
yoloMode: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
|
||||
});
|
||||
// In yolo mode, don't wait for user input - fail the task
|
||||
if (this.stateManager.getGlobalSettingsKey("yoloModeToggled")) {
|
||||
const errorMessage =
|
||||
@@ -2847,9 +2911,7 @@ export class Task {
|
||||
}
|
||||
const { response, text, images, files } = await this.ask(
|
||||
"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 hit repeated tool call failures. Try guiding it with a new prompt.`,
|
||||
);
|
||||
if (response === "messageResponse") {
|
||||
// Display the user's message in the chat UI
|
||||
@@ -3579,14 +3641,71 @@ export class Task {
|
||||
this.api.getModel().id,
|
||||
);
|
||||
const errorMessage = clineError.serialize();
|
||||
|
||||
const isStreamingSpendLimitError = clineError.isErrorType(
|
||||
ClineErrorType.SpendLimit,
|
||||
);
|
||||
// Auto-retry for streaming failures (skip for spend limit errors)
|
||||
// Inference cap and daily free-model limits reset in hours, so
|
||||
// retrying only burns backoff before surfacing the actionable UI.
|
||||
// Mirrors the non-streaming gating in attemptApiRequest.
|
||||
const isStreamingQuotaExceededError = clineError.isErrorType(
|
||||
ClineErrorType.QuotaExceeded,
|
||||
);
|
||||
const isStreamingClineFreeModelLimitError = clineError.isErrorType(
|
||||
ClineErrorType.ClineFreeModelLimit,
|
||||
);
|
||||
// A retired free model (promotion ended) can never answer, so a
|
||||
// retry against it is guaranteed to fail again.
|
||||
const isStreamingClineFreePromotionEndedError =
|
||||
clineError.isErrorType(ClineErrorType.ClineFreePromotionEnded);
|
||||
const isStreamingNonRetriableError =
|
||||
isStreamingSpendLimitError ||
|
||||
isStreamingQuotaExceededError ||
|
||||
isStreamingClineFreeModelLimitError ||
|
||||
isStreamingClineFreePromotionEndedError;
|
||||
const willAutoRetryStreamingFailure =
|
||||
!isStreamingNonRetriableError &&
|
||||
this.taskState.autoRetryAttempts < 3;
|
||||
|
||||
// Mirror the SDK extension's provider-failure reporting for
|
||||
// mid-stream failures (see attemptApiRequest for the
|
||||
// first-chunk equivalent). Only failures that surface to
|
||||
// the user are reported — auto-retried attempts emit
|
||||
// nothing, matching the SDK extension.
|
||||
// isWaitingForFirstChunk gate: first-chunk failures are
|
||||
// already reported inside attemptApiRequest's catch, and a
|
||||
// declined retry rethrows a generic error that unwinds to
|
||||
// this catch — the flag is only cleared after the first
|
||||
// chunk yields, so it cleanly excludes those re-thrown
|
||||
// pre-stream failures from being counted twice.
|
||||
if (
|
||||
!isStreamingSpendLimitError &&
|
||||
this.taskState.autoRetryAttempts < 3
|
||||
!this.taskState.isWaitingForFirstChunk &&
|
||||
!willAutoRetryStreamingFailure &&
|
||||
!this.taskState.abort
|
||||
) {
|
||||
const { providerId: midStreamProviderId } =
|
||||
this.getCurrentProviderInfo();
|
||||
telemetryService.captureProviderApiError({
|
||||
ulid: this.ulid,
|
||||
model: this.api.getModel().id,
|
||||
provider: midStreamProviderId,
|
||||
errorMessage: clineError.message,
|
||||
errorStatus: clineError._error?.status,
|
||||
requestId: clineError._error?.request_id,
|
||||
errorType: ClineError.getErrorType(clineError),
|
||||
failurePhase: "streaming",
|
||||
// This catch also wraps post-stream processing, so a
|
||||
// failure here is not necessarily a provider error;
|
||||
// only a positive detector match is a confident
|
||||
// classification.
|
||||
...(checkContextWindowExceededError(error)
|
||||
? { errorClass: "context_window_exceeded" as const }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-retry for streaming failures (skip for non-retriable errors)
|
||||
if (willAutoRetryStreamingFailure) {
|
||||
this.taskState.autoRetryAttempts++;
|
||||
|
||||
// Calculate exponential backoff for streaming failures: 2s, 4s, 8s
|
||||
@@ -3618,7 +3737,7 @@ export class Task {
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
!isStreamingSpendLimitError &&
|
||||
!isStreamingNonRetriableError &&
|
||||
this.taskState.autoRetryAttempts >= 3
|
||||
) {
|
||||
// Show error_retry with failed flag to indicate all retries exhausted
|
||||
@@ -3826,15 +3945,20 @@ export class Task {
|
||||
const { model, providerId } = this.getCurrentProviderInfo();
|
||||
const reqId = this.getApiRequestIdSafe();
|
||||
|
||||
// Minimal diagnostics: structured log and telemetry
|
||||
telemetryService.captureProviderApiError({
|
||||
ulid: this.ulid,
|
||||
model: model.id,
|
||||
provider: providerId,
|
||||
errorMessage: "empty_assistant_message",
|
||||
requestId: reqId,
|
||||
isNativeToolCall: this.useNativeToolCalls,
|
||||
});
|
||||
// Minimal diagnostics: structured log and telemetry. Reported
|
||||
// only when no auto-retry follows, consistent with the other
|
||||
// provider-failure sites — occurrences an auto-retry absorbs
|
||||
// emit nothing.
|
||||
if (this.taskState.autoRetryAttempts >= 3) {
|
||||
telemetryService.captureProviderApiError({
|
||||
ulid: this.ulid,
|
||||
model: model.id,
|
||||
provider: providerId,
|
||||
errorMessage: "empty_assistant_message",
|
||||
requestId: reqId,
|
||||
isNativeToolCall: this.useNativeToolCalls,
|
||||
});
|
||||
}
|
||||
|
||||
const baseErrorMessage =
|
||||
"Invalid API Response: The provider returned an empty or unparsable response. This is a provider-side issue where the model failed to generate valid output or returned tool calls that Cline cannot process. Retrying the request may help resolve this issue.";
|
||||
|
||||
@@ -879,12 +879,18 @@ export class SubagentRunner {
|
||||
const isOrgClinePassRestrictionError = parsedError.isErrorType(
|
||||
ClineErrorType.OrgClinePassRestriction,
|
||||
);
|
||||
// A retired free model (promotion ended) can never answer, so a retry
|
||||
// against it is guaranteed to fail again.
|
||||
const isClineFreePromotionEndedError = parsedError.isErrorType(
|
||||
ClineErrorType.ClineFreePromotionEnded,
|
||||
);
|
||||
|
||||
if (
|
||||
isAuthError ||
|
||||
isBalanceError ||
|
||||
isEntitlementError ||
|
||||
isOrgClinePassRestrictionError
|
||||
isOrgClinePassRestrictionError ||
|
||||
isClineFreePromotionEndedError
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -51,12 +51,18 @@ import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
|
||||
import { exportVSCodeStorageToSharedFiles } from "./hosts/vscode/vscode-to-file-migration"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { AuthService } from "./services/auth/AuthService"
|
||||
import { initializeRolloutStanddown } from "./services/auth/rollout-standdown"
|
||||
import { LogoutReason } from "./services/auth/types"
|
||||
import { telemetryService } from "./services/telemetry"
|
||||
import type { RolloutBundleActivation } from "./services/telemetry/rollout-metadata"
|
||||
import { LG_TASK_URI_PATH, SharedUriHandler, TASK_URI_PATH } from "./services/uri/SharedUriHandler"
|
||||
import { ShowMessageType } from "./shared/proto/host/window"
|
||||
import { fileExistsAtPath } from "./utils/fs"
|
||||
|
||||
export async function reportRolloutActivation(input: RolloutBundleActivation): Promise<void> {
|
||||
await telemetryService.captureRolloutBundleActivated(input)
|
||||
}
|
||||
|
||||
// This method is called when the VS Code extension is activated.
|
||||
// NOTE: This is VS Code specific - services that should be registered
|
||||
// for all-platform should be registered in common.ts.
|
||||
@@ -67,6 +73,14 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// IMPORTANT: This must be done before any service can be registered
|
||||
setupHostProvider(context)
|
||||
|
||||
// Give the rollout stand-down check access to the loader's cohort memento
|
||||
// (no-op outside combined rollout builds — see rollout-standdown.ts). The
|
||||
// reload action is injected here because direct vscode.commands usage is
|
||||
// restricted to this file by the host-bridge lint rules.
|
||||
initializeRolloutStanddown(context, () => {
|
||||
vscode.commands.executeCommand("workbench.action.reloadWindow")
|
||||
})
|
||||
|
||||
// 2. Clean up legacy data patterns within VSCode's native storage.
|
||||
// Moves workspace→global keys, task history→file, custom instructions→rules, etc.
|
||||
// Must run BEFORE the file export so we copy clean state.
|
||||
|
||||
@@ -3,7 +3,15 @@ import path from "path"
|
||||
import proxyquire from "proxyquire"
|
||||
import sinon from "sinon"
|
||||
|
||||
const DEFAULT_OUTPUT_LINES = ['{"type":"text","text":"Hello"}', '{"type":"text","text":" world"}']
|
||||
|
||||
// Mutable state the mock factories read, so individual tests can simulate
|
||||
// different CLI output streams and exit results.
|
||||
let mockOutputLines = DEFAULT_OUTPUT_LINES
|
||||
let mockProcessError: Error | null = null
|
||||
|
||||
const createMockProcess = () => {
|
||||
const exitCode = mockProcessError ? ((mockProcessError as any).exitCode ?? 1) : 0
|
||||
const mockProcess = {
|
||||
stdin: {
|
||||
write: sinon.fake(),
|
||||
@@ -18,15 +26,20 @@ const createMockProcess = () => {
|
||||
},
|
||||
on: sinon.fake((event, callback) => {
|
||||
if (event === "close") {
|
||||
setImmediate(() => callback(0))
|
||||
setImmediate(() => callback(exitCode))
|
||||
}
|
||||
if (event === "error") {
|
||||
}
|
||||
}),
|
||||
killed: false,
|
||||
kill: sinon.fake(),
|
||||
exitCode: 0,
|
||||
then: (onResolve: (value: any) => void) => {
|
||||
exitCode,
|
||||
then: (onResolve: (value: any) => void, onReject?: (reason: any) => void) => {
|
||||
// execa's process promise rejects on nonzero exit (default `reject: true`)
|
||||
if (mockProcessError && onReject) {
|
||||
setImmediate(() => onReject(mockProcessError))
|
||||
return Promise.resolve()
|
||||
}
|
||||
setImmediate(() => onResolve({ exitCode: 0 }))
|
||||
return Promise.resolve({ exitCode: 0 })
|
||||
},
|
||||
@@ -42,9 +55,8 @@ const createMockProcess = () => {
|
||||
const createMockReadlineInterface = () => {
|
||||
const mockInterface = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// Simulate Claude CLI JSON output - yield a few chunks then end
|
||||
yield '{"type":"text","text":"Hello"}'
|
||||
yield '{"type":"text","text":" world"}'
|
||||
// Simulate Claude CLI JSON output - yield the configured chunks then end
|
||||
yield* mockOutputLines
|
||||
// Iterator ends naturally when function returns
|
||||
return
|
||||
},
|
||||
@@ -79,6 +91,8 @@ describe("Claude Code Integration", () => {
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
mockOutputLines = DEFAULT_OUTPUT_LINES
|
||||
mockProcessError = null
|
||||
})
|
||||
|
||||
const itCallsTheScriptWithAFile = (systemPrompt: string) => {
|
||||
@@ -160,4 +174,60 @@ describe("Claude Code Integration", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("when the process exits with code 1", () => {
|
||||
// Mimics execa's rejection: an Error carrying exitCode, whose message
|
||||
// includes the full command after ": "
|
||||
const createExitCodeOneError = () => {
|
||||
const error = new Error("Command failed with exit code 1: claude --system-prompt aaa --verbose")
|
||||
;(error as any).exitCode = 1
|
||||
return error
|
||||
}
|
||||
|
||||
const collectChunks = async () => {
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of runClaudeCode({
|
||||
systemPrompt: "test",
|
||||
messages: [],
|
||||
modelId: "test",
|
||||
path: scriptPath,
|
||||
})) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
describe("after a result chunk was streamed (max-turns exit)", () => {
|
||||
beforeEach(() => {
|
||||
mockOutputLines = [
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}',
|
||||
'{"type":"result","subtype":"success","is_error":false,"num_turns":1}',
|
||||
]
|
||||
mockProcessError = createExitCodeOneError()
|
||||
})
|
||||
|
||||
it("suppresses the error and yields the full response", async () => {
|
||||
const chunks = await collectChunks()
|
||||
|
||||
expect(chunks).to.have.length(2)
|
||||
expect((chunks[1] as { type: string }).type).to.equal("result")
|
||||
})
|
||||
})
|
||||
|
||||
describe("without a result chunk", () => {
|
||||
beforeEach(() => {
|
||||
mockOutputLines = ['{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}']
|
||||
mockProcessError = createExitCodeOneError()
|
||||
})
|
||||
|
||||
it("throws", async () => {
|
||||
try {
|
||||
await collectChunks()
|
||||
expect.fail("expected runClaudeCode to throw")
|
||||
} catch (error) {
|
||||
expect((error as Error).message).to.include("Command failed with exit code 1")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,13 @@ export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator
|
||||
partialData: null,
|
||||
}
|
||||
|
||||
// Track whether we've received a result chunk from Claude Code.
|
||||
// When --max-turns 1 is set and the model produces a tool_use response (native tool calling),
|
||||
// Claude Code exits with code 1 ("Reached maximum number of turns"). But by that point,
|
||||
// the assistant message and result have already been streamed and yielded. In that case,
|
||||
// the exit code 1 is expected and we should not throw.
|
||||
let hasReceivedResult = false
|
||||
|
||||
try {
|
||||
cProcess.stderr.on("data", (data) => {
|
||||
processState.stderrLogs += data.toString()
|
||||
@@ -80,6 +87,11 @@ export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator
|
||||
continue
|
||||
}
|
||||
|
||||
// Track result chunks so we know the response is complete
|
||||
if (typeof chunk !== "string" && chunk.type === "result") {
|
||||
hasReceivedResult = true
|
||||
}
|
||||
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
@@ -98,9 +110,30 @@ export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
// When --max-turns 1 is set and the model uses native tool calling instead of
|
||||
// XML-formatted text tool calls, Claude Code exits with code 1 ("Reached maximum
|
||||
// number of turns"). If we already received and yielded the result chunk, the
|
||||
// response is complete — suppress the error and let the generator end normally.
|
||||
if (hasReceivedResult && (err as any)?.exitCode === 1) {
|
||||
Logger.log("Claude Code exited with max_turns limit — response already yielded, suppressing error.")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.error(`Error during Claude Code execution:`, err)
|
||||
|
||||
if (processState.stderrLogs.includes("unknown option '--system-prompt-file'")) {
|
||||
// Collect all available error details from multiple sources.
|
||||
// execa attaches stderr/stdout to the error object, and we also capture stderr via our stream listener.
|
||||
const execaStderr = (err as any)?.stderr?.trim?.() || ""
|
||||
const execaStdout = (err as any)?.stdout?.trim?.() || ""
|
||||
const collectedStderr = processState.stderrLogs?.trim() || ""
|
||||
// Use the most informative stderr source available
|
||||
const stderrContent = collectedStderr || execaStderr
|
||||
|
||||
Logger.error(
|
||||
`Claude Code error details — stderr: ${stderrContent || "(empty)"}, stdout tail: ${execaStdout?.slice?.(-500) || "(empty)"}`,
|
||||
)
|
||||
|
||||
if (stderrContent.includes("unknown option '--system-prompt-file'")) {
|
||||
throw new Error(`The Claude Code executable is outdated. Please update it to the latest version.`, {
|
||||
cause: err,
|
||||
})
|
||||
@@ -141,7 +174,13 @@ Anthropic is aware of this issue and is considering a fix: https://github.com/an
|
||||
if (startOfCommand !== -1) {
|
||||
const messageWithoutCommand = err.message.slice(0, startOfCommand).trim()
|
||||
|
||||
throw new Error(`${messageWithoutCommand}\n${processState.stderrLogs?.trim()}`, { cause: err })
|
||||
// Build a descriptive error message using all available error sources
|
||||
const errorDetails = stderrContent || execaStdout?.slice?.(-500) || ""
|
||||
const errorSuffix = errorDetails
|
||||
? `\n${errorDetails}`
|
||||
: "\nNo error details available. Check the Output panel (Cline) for more information."
|
||||
|
||||
throw new Error(`${messageWithoutCommand}${errorSuffix}`, { cause: err })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +203,9 @@ Anthropic is aware of this issue and is considering a fix: https://github.com/an
|
||||
const claudeCodeTools = [
|
||||
"Task",
|
||||
"TaskOutput",
|
||||
"TaskStop",
|
||||
"Bash",
|
||||
"PowerShell",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read",
|
||||
@@ -174,9 +215,10 @@ const claudeCodeTools = [
|
||||
"WebFetch",
|
||||
"TodoWrite",
|
||||
"WebSearch",
|
||||
"TaskStop",
|
||||
"AskUserQuestion",
|
||||
"Skill",
|
||||
"Monitor",
|
||||
"ToolSearch",
|
||||
"EnterPlanMode",
|
||||
"ExitPlanMode",
|
||||
"EnterWorktree",
|
||||
@@ -184,7 +226,9 @@ const claudeCodeTools = [
|
||||
"CronCreate",
|
||||
"CronDelete",
|
||||
"CronList",
|
||||
"ToolSearch",
|
||||
"PushNotification",
|
||||
"RemoteTrigger",
|
||||
"ScheduleWakeup",
|
||||
].join(",")
|
||||
|
||||
const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BannerService } from "../banner/BannerService"
|
||||
import { AuthInvalidTokenError, AuthNetworkError } from "../error/ClineError"
|
||||
import { featureFlagsService } from "../feature-flags"
|
||||
import { ClineAuthProvider } from "./providers/ClineAuthProvider"
|
||||
import { notifyRolloutStanddown, shouldStandDownAuth } from "./rollout-standdown"
|
||||
import { LogoutReason } from "./types"
|
||||
|
||||
export type ServiceConfig = {
|
||||
@@ -187,7 +188,7 @@ export class AuthService {
|
||||
Logger.error("Token is invalid or expired:", error)
|
||||
this._clineAuthInfo = null
|
||||
this._authenticated = false
|
||||
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.ERROR_RECOVERY)
|
||||
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.TOKEN_INVALID)
|
||||
authStatusChanged = true
|
||||
} else if (error instanceof AuthNetworkError) {
|
||||
Logger.error("Network error refreshing token", error)
|
||||
@@ -258,6 +259,16 @@ export class AuthService {
|
||||
return String.create({ value: "Already authenticated" })
|
||||
}
|
||||
|
||||
// Rollout stand-down: the next bundle already holds Cline credentials
|
||||
// on this machine, so signing in from this legacy straggler window
|
||||
// would create a competing token family next never adopts (its
|
||||
// migration never overwrites an existing cline entry). Point the user
|
||||
// at the window reload that switches them to the next bundle instead.
|
||||
if (shouldStandDownAuth()) {
|
||||
notifyRolloutStanddown()
|
||||
return String.create({ value: "Reload this window to sign in on the new version of Cline" })
|
||||
}
|
||||
|
||||
const callbackUrl = await HostProvider.get().getCallbackUrl("/auth")
|
||||
|
||||
const authUrl = await this._provider.getAuthRequest(callbackUrl)
|
||||
@@ -320,13 +331,15 @@ export class AuthService {
|
||||
Logger.warn("No user found after restoring auth token")
|
||||
this._authenticated = false
|
||||
this._clineAuthInfo = null
|
||||
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.ERROR_RECOVERY)
|
||||
// Fires on every window open for users with no stored session
|
||||
// (e.g. API-key users) — not a logout, hence the dedicated reason.
|
||||
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.NO_STORED_SESSION)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error restoring auth token:", error)
|
||||
this._authenticated = false
|
||||
this._clineAuthInfo = null
|
||||
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.ERROR_RECOVERY)
|
||||
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.RESTORE_ERROR)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Tests for the user.auth_logged_out reason mapping: the old catch-all
|
||||
// ERROR_RECOVERY is split into NO_STORED_SESSION / TOKEN_INVALID / RESTORE_ERROR.
|
||||
|
||||
import { expect } from "chai"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as sinon from "sinon"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { resetTelemetryService } from "@/services/telemetry"
|
||||
import { TelemetryService } from "@/services/telemetry/TelemetryService"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { AuthInvalidTokenError, AuthNetworkError } from "../../error/ClineError"
|
||||
import { AuthService, ClineAuthInfo } from "../AuthService"
|
||||
import { LogoutReason } from "../types"
|
||||
|
||||
class TestableAuthService extends AuthService {
|
||||
constructor(controller: Controller) {
|
||||
super(controller)
|
||||
}
|
||||
}
|
||||
|
||||
/** The exported telemetryService proxy resolves the real service asynchronously. */
|
||||
async function flushTelemetry(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
describe("AuthService logout telemetry reasons", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let capturedLoggedOut: sinon.SinonStub
|
||||
let service: TestableAuthService
|
||||
let retrieveClineAuthInfoStub: sinon.SinonStub
|
||||
|
||||
const authInfo: ClineAuthInfo = {
|
||||
idToken: "id-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: Date.now() / 1000 + 3600,
|
||||
userInfo: {
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
displayName: "Test User",
|
||||
email: "test@example.com",
|
||||
id: "user_1",
|
||||
organizations: [],
|
||||
},
|
||||
provider: "cline",
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
sandbox.stub(Logger, "warn")
|
||||
sandbox.stub(Logger, "error")
|
||||
sandbox.stub(Logger, "info")
|
||||
sandbox.stub(Logger, "debug")
|
||||
|
||||
capturedLoggedOut = sandbox.stub(TelemetryService.prototype, "captureAuthLoggedOut")
|
||||
sandbox.stub(TelemetryService, "create").resolves(new TelemetryService([], {} as any))
|
||||
resetTelemetryService()
|
||||
|
||||
service = new TestableAuthService({} as Controller)
|
||||
sandbox.stub(service, "sendAuthStatusUpdate").resolves()
|
||||
retrieveClineAuthInfoStub = sandbox.stub((service as any)._provider, "retrieveClineAuthInfo")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetTelemetryService()
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("restoreRefreshTokenAndRetrieveAuthInfo()", () => {
|
||||
it("reports no_stored_session when restore finds no session", async () => {
|
||||
retrieveClineAuthInfoStub.resolves(null)
|
||||
|
||||
await service.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
await flushTelemetry()
|
||||
|
||||
expect(capturedLoggedOut.firstCall.args).to.deep.equal(["cline", LogoutReason.NO_STORED_SESSION])
|
||||
})
|
||||
|
||||
it("reports restore_error when restore throws", async () => {
|
||||
retrieveClineAuthInfoStub.rejects(new Error("secret storage unavailable"))
|
||||
|
||||
await service.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
await flushTelemetry()
|
||||
|
||||
expect(capturedLoggedOut.firstCall.args).to.deep.equal(["cline", LogoutReason.RESTORE_ERROR])
|
||||
})
|
||||
|
||||
it("reports nothing when restore succeeds", async () => {
|
||||
retrieveClineAuthInfoStub.resolves(authInfo)
|
||||
|
||||
await service.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
await flushTelemetry()
|
||||
|
||||
expect(capturedLoggedOut.called).to.be.false
|
||||
expect((service as any)._authenticated).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAuthToken() refresh failures", () => {
|
||||
beforeEach(() => {
|
||||
// Signed-in session whose access token needs a refresh.
|
||||
;(service as any)._clineAuthInfo = { ...authInfo, expiresAt: Date.now() / 1000 - 60 }
|
||||
;(service as any)._authenticated = true
|
||||
sandbox.stub((service as any)._provider, "shouldRefreshIdToken").resolves(true)
|
||||
})
|
||||
|
||||
it("reports token_invalid when the refresh token is rejected", async () => {
|
||||
retrieveClineAuthInfoStub.rejects(new AuthInvalidTokenError("invalid or expired token"))
|
||||
|
||||
const token = await service.getAuthToken()
|
||||
await flushTelemetry()
|
||||
|
||||
expect(token).to.be.null
|
||||
expect(capturedLoggedOut.firstCall.args).to.deep.equal(["cline", LogoutReason.TOKEN_INVALID])
|
||||
})
|
||||
|
||||
it("reports nothing on transient network failures", async () => {
|
||||
retrieveClineAuthInfoStub.rejects(new AuthNetworkError("status: 503"))
|
||||
|
||||
const token = await service.getAuthToken()
|
||||
await flushTelemetry()
|
||||
|
||||
expect(token).to.be.null
|
||||
expect(capturedLoggedOut.called).to.be.false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as assert from "node:assert"
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import {
|
||||
decideStanddown,
|
||||
nextBundleOwnsClineAccount,
|
||||
resetRolloutStanddownForTests,
|
||||
shouldStandDownAuth,
|
||||
} from "../rollout-standdown"
|
||||
|
||||
const originalVariant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
const originalDataDir = process.env.CLINE_DATA_DIR
|
||||
|
||||
afterEach(() => {
|
||||
resetRolloutStanddownForTests()
|
||||
restoreEnv("CLINE_ROLLOUT_VARIANT", originalVariant)
|
||||
restoreEnv("CLINE_DATA_DIR", originalDataDir)
|
||||
})
|
||||
|
||||
describe("rollout auth stand-down", () => {
|
||||
const base = {
|
||||
envOverride: undefined,
|
||||
settingOverride: undefined,
|
||||
cached: undefined,
|
||||
previousFailure: false,
|
||||
nextOwnsClineAccount: true,
|
||||
}
|
||||
|
||||
it("stands down only when the cached cohort assignment is next", () => {
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next" }), true)
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "legacy" }), false)
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: undefined }), false)
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "garbage" }), false)
|
||||
})
|
||||
|
||||
it("never stands down until the next bundle holds the Cline credentials", () => {
|
||||
// Single-window case: the cohort flag flipped mid-session, but next has
|
||||
// never activated (or holds no cline refresh token) — this window is the
|
||||
// sole owner of the token family and must keep working untouched.
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next", nextOwnsClineAccount: false }), false)
|
||||
// Both conditions hold: straggler alongside an active next bundle.
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next", nextOwnsClineAccount: true }), true)
|
||||
})
|
||||
|
||||
it("never stands down when the user forced a bundle (mirrors the loader's precedence)", () => {
|
||||
// Forced legacy: the user deliberately keeps this window on legacy.
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next", envOverride: "legacy" }), false)
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next", settingOverride: "legacy" }), false)
|
||||
// Forced next: the loader would never have activated this bundle; be safe anyway.
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next", envOverride: "next" }), false)
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next", settingOverride: "next" }), false)
|
||||
// "auto" is not an override.
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next", settingOverride: "auto" }), true)
|
||||
})
|
||||
|
||||
it("never stands down when the loader crash-pinned this VSIX version to legacy", () => {
|
||||
assert.strictEqual(decideStanddown({ ...base, cached: "next", previousFailure: true }), false)
|
||||
})
|
||||
|
||||
it("shouldStandDownAuth is inert outside combined rollout legacy builds", () => {
|
||||
// Standalone/marketplace/dev builds have no CLINE_ROLLOUT_VARIANT: a
|
||||
// leftover loader memento must never disable auth there.
|
||||
delete process.env.CLINE_ROLLOUT_VARIANT
|
||||
assert.strictEqual(shouldStandDownAuth(), false)
|
||||
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "next"
|
||||
assert.strictEqual(shouldStandDownAuth(), false)
|
||||
})
|
||||
|
||||
it("shouldStandDownAuth is inert before initialization even in rollout legacy builds", () => {
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "legacy"
|
||||
assert.strictEqual(shouldStandDownAuth(), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("nextBundleOwnsClineAccount", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "standdown-probe-"))
|
||||
process.env.CLINE_DATA_DIR = tempDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeProvidersFile(contents: string): void {
|
||||
const dir = path.join(tempDir, "settings")
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, "providers.json"), contents)
|
||||
}
|
||||
|
||||
it("is false when providers.json does not exist (next never activated)", () => {
|
||||
assert.strictEqual(nextBundleOwnsClineAccount(), false)
|
||||
})
|
||||
|
||||
it("is false when the cline entry has no auth (e.g. CLI picked a model but never signed in)", () => {
|
||||
writeProvidersFile(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
providers: {
|
||||
cline: {
|
||||
settings: { provider: "cline", model: "anthropic/claude-sonnet-4.6" },
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
tokenSource: "manual",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
assert.strictEqual(nextBundleOwnsClineAccount(), false)
|
||||
})
|
||||
|
||||
it("is true when the cline entry holds a refresh token (migrated or signed in on next)", () => {
|
||||
writeProvidersFile(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
providers: {
|
||||
cline: {
|
||||
settings: {
|
||||
provider: "cline",
|
||||
auth: { accessToken: "at", refreshToken: "rt-1", accountId: "user_1" },
|
||||
},
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
tokenSource: "migration",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
assert.strictEqual(nextBundleOwnsClineAccount(), true)
|
||||
})
|
||||
|
||||
it("fails open on malformed json", () => {
|
||||
writeProvidersFile("{not json")
|
||||
assert.strictEqual(nextBundleOwnsClineAccount(), false)
|
||||
})
|
||||
})
|
||||
|
||||
function restoreEnv(key: "CLINE_ROLLOUT_VARIANT" | "CLINE_DATA_DIR", value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { type ClineAccountUserInfo, type ClineAuthInfo } from "../AuthService"
|
||||
import { parseJwtPayload } from "../oca/utils/utils"
|
||||
import { notifyRolloutStanddown, shouldStandDownAuth } from "../rollout-standdown"
|
||||
|
||||
interface ClineAuthApiUser {
|
||||
subject: string | null
|
||||
@@ -188,6 +189,23 @@ export class ClineAuthProvider {
|
||||
}
|
||||
|
||||
if (await this.shouldRefreshIdToken(storedAuthData.refreshToken, storedAuthData.expiresAt)) {
|
||||
// Rollout stand-down: this machine is assigned to the next
|
||||
// bundle AND next already holds a copy of these credentials,
|
||||
// so this legacy window must never rotate the shared refresh
|
||||
// token again (a rotation from here would strand next with a
|
||||
// consumed token). Keep using the current access token until
|
||||
// it truly expires, then surface a "reload this window" notice
|
||||
// instead of refreshing. The stored blob is deliberately left
|
||||
// intact.
|
||||
if (shouldStandDownAuth()) {
|
||||
if (this.timeUntilExpiry(storedAuthData.idToken) > 30) {
|
||||
return storedAuthData
|
||||
}
|
||||
Logger.debug("Rollout stand-down: suppressing token refresh in legacy straggler window")
|
||||
notifyRolloutStanddown()
|
||||
return null
|
||||
}
|
||||
|
||||
// If the token hasn't expired yet,
|
||||
// and it failed the first refresh attempt
|
||||
// with something other than invalid token
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import * as vscode from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { getExtensionVariant } from "@/services/telemetry/rollout-metadata"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rollout auth stand-down
|
||||
//
|
||||
// During the combined-VSIX A/B rollout, this (legacy) bundle and the next
|
||||
// (SDK) bundle keep Cline-account credentials in different stores, and the
|
||||
// refresh endpoint ROTATES the refresh token: whichever bundle refreshes
|
||||
// last strands the other with a consumed token and a hard logout.
|
||||
//
|
||||
// That hazard needs TWO holders of the same token family. The second holder
|
||||
// is born when the next bundle first activates on this machine and its
|
||||
// credential migration copies the legacy blob into providers.json (or the
|
||||
// user signs in on next directly). So a legacy window stands down only when
|
||||
// BOTH are true:
|
||||
//
|
||||
// 1. the loader's cached cohort assignment says this machine's next window
|
||||
// reload activates the next bundle, and
|
||||
// 2. next demonstrably holds Cline-account credentials on this machine
|
||||
// (providers.json has a cline entry with a refresh token).
|
||||
//
|
||||
// Until (2), the user may be sitting in their one and only window after the
|
||||
// cohort flag flipped mid-session — that window is the SOLE owner of the
|
||||
// token family, its rotations are harmless, and they keep the blob fresh for
|
||||
// the eventual migration. It must keep working untouched, indefinitely.
|
||||
//
|
||||
// Once both hold, the straggler stands down: it never refreshes/rotates
|
||||
// again, keeps working until its current access token naturally expires, and
|
||||
// then presents a clear "reload this window to continue on the new version"
|
||||
// message.
|
||||
//
|
||||
// Critically, standing down NEVER clears the stored credential blob — the
|
||||
// next bundle's migration reads it, and a demotion back to legacy must find
|
||||
// it intact.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Loader-owned state and settings, mirrored from apps/vscode-rollout on main
|
||||
* (src/cohort.ts) — keep in sync. The loader shares this extension's
|
||||
* globalState, so the memento keys are directly readable here.
|
||||
*/
|
||||
export const COHORT_STATE_KEY = "cline.rollout.bundle"
|
||||
export const FAILED_VERSION_STATE_KEY = "cline.rollout.nextActivationFailedVersion"
|
||||
export const BUNDLE_OVERRIDE_ENV = "CLINE_BUNDLE_OVERRIDE"
|
||||
export const SETTING_BUNDLE_OVERRIDE = "bundleOverride"
|
||||
const NIGHTLY_EXTENSION_NAME = "cline-nightly"
|
||||
|
||||
export interface StanddownInputs {
|
||||
/** CLINE_BUNDLE_OVERRIDE, if set. Beats everything (mirrors the loader). */
|
||||
envOverride: string | undefined
|
||||
/** The <prefix>.rollout.bundleOverride user setting ("auto" = no override). */
|
||||
settingOverride: string | undefined
|
||||
/** The loader's cached cohort assignment ("next" | "legacy" | undefined). */
|
||||
cached: string | undefined
|
||||
/** The next bundle failed to activate on this VSIX version (loader pinned legacy). */
|
||||
previousFailure: boolean
|
||||
/** The next bundle holds Cline-account credentials on this machine (see nextBundleOwnsClineAccount). */
|
||||
nextOwnsClineAccount: boolean
|
||||
}
|
||||
|
||||
function asBundle(value: unknown): "next" | "legacy" | undefined {
|
||||
return value === "next" || value === "legacy" ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a legacy window should stand down its Cline-account auth. Mirrors
|
||||
* the loader's decideBundle() precedence, then additionally requires that the
|
||||
* next bundle has actually taken co-ownership of the Cline account: "the next
|
||||
* window reload on this machine activates next AND next already holds the
|
||||
* credentials". Overrides and the crash pin keep this window fully
|
||||
* functional — the user (or the loader's safety net) explicitly chose legacy
|
||||
* in those cases.
|
||||
*/
|
||||
export function decideStanddown(inputs: StanddownInputs): boolean {
|
||||
const forced = asBundle(inputs.envOverride) ?? asBundle(inputs.settingOverride)
|
||||
if (forced) {
|
||||
return false // forced legacy keeps working; forced next never activates this bundle
|
||||
}
|
||||
if (inputs.previousFailure) {
|
||||
return false
|
||||
}
|
||||
return inputs.cached === "next" && inputs.nextOwnsClineAccount
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the next (SDK) bundle keeps its data. Mirrors the SDK's
|
||||
* resolveDataDirFromEnv() on main (apps/vscode/src/shared/storage/
|
||||
* storage-context.ts) — keep in sync: CLINE_DATA_DIR > CLINE_DIR/data >
|
||||
* ~/.cline/data.
|
||||
*/
|
||||
function resolveNextDataDir(): string {
|
||||
const envDataDir = process.env.CLINE_DATA_DIR?.trim()
|
||||
if (envDataDir) {
|
||||
return envDataDir
|
||||
}
|
||||
const clineDir = process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline")
|
||||
return path.join(clineDir, "data")
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the next bundle demonstrably co-owns the Cline account on this
|
||||
* machine: its providers.json has a cline entry holding a refresh token —
|
||||
* written either by its credential migration on first activation (which
|
||||
* copies this legacy blob) or by a sign-in performed on next (including the
|
||||
* CLI, which shares the same store and also rotates).
|
||||
*
|
||||
* A cline entry WITHOUT auth (e.g. the CLI configured a model but never
|
||||
* signed in) does not count: whoever holds no refresh token cannot rotate,
|
||||
* so this window rotating strands nobody.
|
||||
*
|
||||
* Deliberately fail-open: no file, unreadable, or malformed → false, auth
|
||||
* keeps working.
|
||||
*/
|
||||
export function nextBundleOwnsClineAccount(): boolean {
|
||||
try {
|
||||
const file = path.join(resolveNextDataDir(), "settings", "providers.json")
|
||||
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
providers?: Record<string, { settings?: { auth?: { refreshToken?: unknown } } }>
|
||||
}
|
||||
const refreshToken = parsed?.providers?.cline?.settings?.auth?.refreshToken
|
||||
return typeof refreshToken === "string" && refreshToken.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let extensionContext: vscode.ExtensionContext | undefined
|
||||
let reloadWindowCallback: (() => void) | undefined
|
||||
let notified = false
|
||||
|
||||
/**
|
||||
* Called once from activate() so the cohort memento can be read later.
|
||||
* The window reload is injected as a callback because direct
|
||||
* vscode.commands usage is restricted to extension.ts by the host-bridge
|
||||
* lint rules (src/dev/grit/vscode-api.grit).
|
||||
*/
|
||||
export function initializeRolloutStanddown(context: vscode.ExtensionContext, reloadWindow?: () => void): void {
|
||||
extensionContext = context
|
||||
reloadWindowCallback = reloadWindow
|
||||
}
|
||||
|
||||
function readInputs(context: vscode.ExtensionContext): StanddownInputs {
|
||||
const packageJSON = (context.extension?.packageJSON ?? {}) as {
|
||||
name?: string
|
||||
version?: string
|
||||
}
|
||||
const prefix = packageJSON.name === NIGHTLY_EXTENSION_NAME ? "cline-nightly" : "cline"
|
||||
return {
|
||||
envOverride: process.env[BUNDLE_OVERRIDE_ENV],
|
||||
settingOverride: vscode.workspace.getConfiguration(`${prefix}.rollout`).get<string>(SETTING_BUNDLE_OVERRIDE),
|
||||
cached: context.globalState.get<string>(COHORT_STATE_KEY),
|
||||
previousFailure:
|
||||
packageJSON.version !== undefined &&
|
||||
context.globalState.get<string>(FAILED_VERSION_STATE_KEY) === packageJSON.version,
|
||||
nextOwnsClineAccount: nextBundleOwnsClineAccount(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this window is a legacy straggler on a machine assigned to the
|
||||
* next cohort AND the next bundle already holds the Cline credentials.
|
||||
* Re-reads the loader's memento and providers.json on every call, so both a
|
||||
* demotion (flag dialed back down) and next releasing the credentials
|
||||
* re-enable auth without a reload — and a next window activating alongside
|
||||
* this one engages the stand-down without a reload.
|
||||
*
|
||||
* Only ever true for bundles built by the combined rollout workflow
|
||||
* (CLINE_ROLLOUT_VARIANT="legacy") — standalone/marketplace legacy builds and
|
||||
* dev builds never stand down, even if a loader memento is present.
|
||||
*/
|
||||
export function shouldStandDownAuth(): boolean {
|
||||
if (getExtensionVariant() !== "legacy") {
|
||||
return false
|
||||
}
|
||||
const context = extensionContext
|
||||
if (!context) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return decideStanddown(readInputs(context))
|
||||
} catch (error) {
|
||||
Logger.error("[RolloutStanddown] Failed to evaluate cohort state:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time-per-window notice shown when auth actually stands down (token
|
||||
* expired and refresh was suppressed, or the user tried to sign in here).
|
||||
*/
|
||||
export function notifyRolloutStanddown(): void {
|
||||
if (notified) {
|
||||
return
|
||||
}
|
||||
notified = true
|
||||
const reload = "Reload Window"
|
||||
HostProvider.window
|
||||
.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message:
|
||||
"You've been signed out in this window because this machine was upgraded to the new version of Cline. " +
|
||||
"Reload the window to keep using your account on the new version.",
|
||||
options: { items: [reload] },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.selectedOption === reload) {
|
||||
reloadWindowCallback?.()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error("[RolloutStanddown] Failed to show stand-down notice:", error)
|
||||
})
|
||||
}
|
||||
|
||||
/** Test-only: reset module state between tests. */
|
||||
export function resetRolloutStanddownForTests(): void {
|
||||
extensionContext = undefined
|
||||
reloadWindowCallback = undefined
|
||||
notified = false
|
||||
}
|
||||
@@ -7,8 +7,14 @@ export enum LogoutReason {
|
||||
USER_INITIATED = "user_initiated",
|
||||
/** Auth tokens were cleared in another VSCode window (cross-window sync) */
|
||||
CROSS_WINDOW_SYNC = "cross_window_sync",
|
||||
/** Auth provider encountered an error and cleared tokens */
|
||||
/** @deprecated No longer emitted — split into NO_STORED_SESSION / TOKEN_INVALID / RESTORE_ERROR */
|
||||
ERROR_RECOVERY = "error_recovery",
|
||||
/** Refresh token rejected as invalid/expired — a real involuntary logout */
|
||||
TOKEN_INVALID = "token_invalid",
|
||||
/** Activated with no stored Cline session (e.g. API-key users) — not a logout */
|
||||
NO_STORED_SESSION = "no_stored_session",
|
||||
/** Restoring the stored session on activation threw an error */
|
||||
RESTORE_ERROR = "restore_error",
|
||||
/** Unknown or unspecified reason */
|
||||
UNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ export enum ClineErrorType {
|
||||
QuotaExceeded = "quotaExceeded",
|
||||
Entitlement = "entitlement",
|
||||
OrgClinePassRestriction = "orgClinePassRestriction",
|
||||
ClinePassLimit = "clinePassLimit",
|
||||
ClineFreeModelLimit = "clineFreeModelLimit",
|
||||
ClineFreePromotionEnded = "clineFreePromotionEnded",
|
||||
}
|
||||
|
||||
interface ErrorDetails {
|
||||
@@ -57,6 +60,102 @@ 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.";
|
||||
|
||||
// The daily Cline free-model limit message is dynamic ("Daily free limit reached on
|
||||
// model <id>. Try again in 23h 59m"), so it is matched by its fixed marker and the
|
||||
// reset window is extracted from the trailing "try again in" clause.
|
||||
const CLINE_FREE_MODEL_LIMIT_MARKER = "free limit reached on model";
|
||||
const CLINE_FREE_MODEL_LIMIT_RETRY_MARKER = "try again in ";
|
||||
|
||||
// Once a free promotion ends, the cline-free/ model is removed from the catalog
|
||||
// and the backend answers "model not found" to requests against it. The prefix
|
||||
// gate keeps ordinary model-not-found errors on their generic path. Kept local
|
||||
// (rather than importing shared/cline/free-models) so this file stays free of
|
||||
// import cycles — it is loaded by both the extension host and the webview.
|
||||
const CLINE_FREE_MODEL_ID_PREFIX = "cline-free/";
|
||||
const CLINE_MODEL_NOT_FOUND_MARKER = "model not found";
|
||||
|
||||
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 function isClineFreeModelLimitMessage(text: string): boolean {
|
||||
return text.toLowerCase().includes(CLINE_FREE_MODEL_LIMIT_MARKER);
|
||||
}
|
||||
|
||||
export function isClineModelNotFoundMessage(text: string): boolean {
|
||||
return text.toLowerCase().includes(CLINE_MODEL_NOT_FOUND_MARKER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects a request against a retired free model: once a promotion ends the
|
||||
* cline-free/ model is removed from the catalog and the backend answers "model
|
||||
* not found". Mirrors the CLI's detection in apps/cli (cline-pass-errors.ts).
|
||||
*/
|
||||
export function isClineFreePromotionEndedMessage(
|
||||
text: string,
|
||||
modelId?: string,
|
||||
): boolean {
|
||||
if (!modelId?.toLowerCase().startsWith(CLINE_FREE_MODEL_ID_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
return isClineModelNotFoundMessage(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the reset window ("23h 59m") out of a daily free-limit message so the UI
|
||||
* can tell the user when the model becomes available again.
|
||||
*/
|
||||
export function extractClineFreeModelLimitResetTime(
|
||||
text: string,
|
||||
): string | undefined {
|
||||
const message = text.toLowerCase();
|
||||
const resetStart = message.indexOf(CLINE_FREE_MODEL_LIMIT_RETRY_MARKER);
|
||||
if (resetStart === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resetTime = message
|
||||
.slice(resetStart + CLINE_FREE_MODEL_LIMIT_RETRY_MARKER.length)
|
||||
.trim();
|
||||
return resetTime || undefined;
|
||||
}
|
||||
|
||||
export class ClineError extends Error {
|
||||
readonly title = "ClineError";
|
||||
readonly _error: ErrorDetails;
|
||||
@@ -202,6 +301,43 @@ 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;
|
||||
|
||||
// Daily Cline free-model limits are a different remedy than the ClinePass
|
||||
// limit (pick another model / switch to the paid twin, not usage billing),
|
||||
// so classify them first and keep them off the 429 rate-limit path.
|
||||
if (
|
||||
isClineFreeModelLimitMessage(detailMessage ?? "") ||
|
||||
isClineFreeModelLimitMessage(message ?? "")
|
||||
) {
|
||||
return ClineErrorType.ClineFreeModelLimit;
|
||||
}
|
||||
|
||||
if (
|
||||
isClinePassLimitMessage(detailMessage ?? "") ||
|
||||
isClinePassLimitMessage(message ?? "")
|
||||
) {
|
||||
return ClineErrorType.ClinePassLimit;
|
||||
}
|
||||
|
||||
// Retired free models must be classified before the auth branch: the
|
||||
// backend's model-not-found answer is a 404, which falls inside the
|
||||
// generic 401-428 auth-status range below.
|
||||
const promotionEndedModelId = err.modelId ?? err._error?.modelId;
|
||||
if (
|
||||
isClineFreePromotionEndedMessage(
|
||||
detailMessage ?? "",
|
||||
promotionEndedModelId,
|
||||
) ||
|
||||
isClineFreePromotionEndedMessage(message ?? "", promotionEndedModelId)
|
||||
) {
|
||||
return ClineErrorType.ClineFreePromotionEnded;
|
||||
}
|
||||
|
||||
// Check auth errors
|
||||
const isAuthStatus = status !== undefined && status > 400 && status < 429;
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, it } from "mocha";
|
||||
import "should";
|
||||
import { ClineError, ClineErrorType } from "../ClineError";
|
||||
import {
|
||||
ClineError,
|
||||
ClineErrorType,
|
||||
extractClineFreeModelLimitResetTime,
|
||||
extractClinePassLimitMessage,
|
||||
isClineFreeModelLimitMessage,
|
||||
isClineFreePromotionEndedMessage,
|
||||
isClinePassLimitMessage,
|
||||
} from "../ClineError";
|
||||
|
||||
describe("ClineError", () => {
|
||||
describe("getErrorType", () => {
|
||||
@@ -59,7 +67,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 +97,227 @@ 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);
|
||||
});
|
||||
|
||||
it("should classify daily Cline free model limits separately", () => {
|
||||
const err = new ClineError(
|
||||
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
|
||||
);
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(
|
||||
ClineErrorType.ClineFreeModelLimit,
|
||||
);
|
||||
});
|
||||
|
||||
it("should classify nested daily Cline free model limits as ClineFreeModelLimit", () => {
|
||||
// ClineError maps `error.error` into `details`, matching the real provider error shape.
|
||||
const err = new ClineError({
|
||||
message: "429 Error 429",
|
||||
error: {
|
||||
message:
|
||||
"Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 5h 12m",
|
||||
},
|
||||
});
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(
|
||||
ClineErrorType.ClineFreeModelLimit,
|
||||
);
|
||||
});
|
||||
|
||||
it("should prefer ClineFreeModelLimit over the generic rate-limit patterns", () => {
|
||||
// The message carries "429", which the RATE_LIMIT_PATTERNS would otherwise match.
|
||||
const err = new ClineError({
|
||||
message:
|
||||
"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
|
||||
status: 429,
|
||||
});
|
||||
|
||||
const result = ClineError.getErrorType(err);
|
||||
result!.should.equal(ClineErrorType.ClineFreeModelLimit);
|
||||
(result !== ClineErrorType.RateLimit).should.be.true();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClineFreeModelLimitMessage", () => {
|
||||
it("matches daily and generic free-limit messages", () => {
|
||||
isClineFreeModelLimitMessage(
|
||||
"Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
|
||||
).should.be.true();
|
||||
isClineFreeModelLimitMessage(
|
||||
"Free limit reached on model cline-free/glm-5",
|
||||
).should.be.true();
|
||||
});
|
||||
|
||||
it("does not match unrelated messages", () => {
|
||||
isClineFreeModelLimitMessage(
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
|
||||
).should.be.false();
|
||||
isClineFreeModelLimitMessage("some other error").should.be.false();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractClineFreeModelLimitResetTime", () => {
|
||||
it("extracts the reset window out of the limit message", () => {
|
||||
extractClineFreeModelLimitResetTime(
|
||||
"Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
|
||||
)!.should.equal("23h 59m");
|
||||
});
|
||||
|
||||
it("returns undefined when there is no reset window", () => {
|
||||
(
|
||||
extractClineFreeModelLimitResetTime(
|
||||
"Daily free limit reached on model deepseek/deepseek-v4-flash.",
|
||||
) === undefined
|
||||
).should.be.true();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClineFreePromotionEndedMessage", () => {
|
||||
it("matches model-not-found only for cline-free model ids", () => {
|
||||
isClineFreePromotionEndedMessage(
|
||||
"Error 404: Model not found",
|
||||
"cline-free/glm-5",
|
||||
).should.be.true();
|
||||
isClineFreePromotionEndedMessage(
|
||||
"Error 404: Model not found",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
).should.be.false();
|
||||
isClineFreePromotionEndedMessage(
|
||||
"Error 404: Model not found",
|
||||
undefined,
|
||||
).should.be.false();
|
||||
});
|
||||
|
||||
it("does not match unrelated cline-free errors", () => {
|
||||
isClineFreePromotionEndedMessage(
|
||||
"Network error: socket hang up",
|
||||
"cline-free/glm-5",
|
||||
).should.be.false();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClineFreePromotionEnded classification", () => {
|
||||
it("classifies model-not-found for a cline-free model as ClineFreePromotionEnded", () => {
|
||||
const err = new ClineError(
|
||||
{ message: "Error 404: Model not found" },
|
||||
"cline-free/glm-5",
|
||||
"cline",
|
||||
);
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(
|
||||
ClineErrorType.ClineFreePromotionEnded,
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers ClineFreePromotionEnded over Auth for a 404 with a cline-free model", () => {
|
||||
// status 404 falls inside the generic auth-status range; the
|
||||
// promotion-ended classification must win.
|
||||
const err = new ClineError(
|
||||
{ message: "Error 404: Model not found", status: 404 },
|
||||
"cline-free/glm-5",
|
||||
"cline",
|
||||
);
|
||||
|
||||
const result = ClineError.getErrorType(err);
|
||||
result!.should.equal(ClineErrorType.ClineFreePromotionEnded);
|
||||
(result !== ClineErrorType.Auth).should.be.true();
|
||||
});
|
||||
|
||||
it("classifies the nested provider error shape via the serialized modelId", () => {
|
||||
// ErrorRow re-parses the serialized error in the webview; the model id
|
||||
// rides the payload, not the message.
|
||||
const original = new ClineError(
|
||||
{
|
||||
status: 404,
|
||||
error: { message: "Model not found" },
|
||||
},
|
||||
"cline-free/glm-5",
|
||||
"cline-pass",
|
||||
);
|
||||
|
||||
const reparsed = ClineError.parse(original.serialize())!;
|
||||
ClineError.getErrorType(reparsed)!.should.equal(
|
||||
ClineErrorType.ClineFreePromotionEnded,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps model-not-found for a non-free model on the generic path", () => {
|
||||
const err = new ClineError(
|
||||
{ message: "Error 404: Model not found", status: 404 },
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"cline",
|
||||
);
|
||||
|
||||
const result = ClineError.getErrorType(err);
|
||||
(result !== ClineErrorType.ClineFreePromotionEnded).should.be.true();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,49 @@ describe("Telemetry system is abstracted and can easily switch between providers
|
||||
await localService.dispose()
|
||||
})
|
||||
|
||||
it("should derive host_plugin_version from the host's clineVersion", async () => {
|
||||
const cases = [
|
||||
{
|
||||
hostVersion: {
|
||||
platform: "IntelliJ IDEA Ultimate",
|
||||
version: "2026.1.1",
|
||||
clineType: "Cline for JetBrains",
|
||||
clineVersion: "1.1.61",
|
||||
},
|
||||
expectedHostPluginVersion: "1.1.61" as string | undefined,
|
||||
},
|
||||
{
|
||||
hostVersion: {
|
||||
platform: "VS Code",
|
||||
version: "1.103.0",
|
||||
clineType: "VSCode Extension",
|
||||
},
|
||||
expectedHostPluginVersion: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
for (const { hostVersion, expectedHostPluginVersion } of cases) {
|
||||
const sandbox = sinon.createSandbox()
|
||||
let service: TelemetryService | undefined
|
||||
try {
|
||||
const provider = new NoOpTelemetryProvider()
|
||||
const logSpy = sandbox.spy(provider, "log")
|
||||
sandbox.stub(HostProvider.env, "getHostVersion").resolves(hostVersion)
|
||||
sandbox.stub(TelemetryProviderFactory, "createProviders").resolves([provider])
|
||||
|
||||
service = await TelemetryService.create()
|
||||
logSpy.resetHistory()
|
||||
service.captureTaskCreated("task-123", "openai")
|
||||
|
||||
assert.ok(logSpy.calledOnce, `service should emit an event (${hostVersion.clineType})`)
|
||||
assert.strictEqual(logSpy.firstCall.args[1]?.host_plugin_version, expectedHostPluginVersion)
|
||||
} finally {
|
||||
sandbox.restore()
|
||||
await service?.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should include remote workspace metadata on workspace.initialized events", async () => {
|
||||
const noOpProvider = new NoOpTelemetryProvider()
|
||||
const logSpy = sinon.spy(noOpProvider, "log")
|
||||
|
||||
@@ -11,6 +11,13 @@ import { Mode } from "@/shared/storage/types"
|
||||
import { version as extensionVersion } from "../../../package.json"
|
||||
import { setDistinctId } from "../logging/distinctId"
|
||||
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
|
||||
import {
|
||||
getRolloutErrorProperties,
|
||||
getRolloutTelemetryMetadata,
|
||||
ROLLOUT_BUNDLE_ACTIVATED_EVENT,
|
||||
type RolloutBundleActivation,
|
||||
type RolloutTelemetryMetadata,
|
||||
} from "./rollout-metadata"
|
||||
import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
|
||||
|
||||
/**
|
||||
@@ -80,6 +87,12 @@ export type TelemetryMetadata = {
|
||||
* all use the same extension or plugin.
|
||||
*/
|
||||
cline_type: string
|
||||
/**
|
||||
* The version of the host-side Cline distribution package: the JetBrains plugin version
|
||||
* (e.g. 1.1.61) on JetBrains, the extension version on VSCode (where it matches
|
||||
* `extension_version`). Absent when the host does not report one (e.g. CLI).
|
||||
*/
|
||||
host_plugin_version?: string
|
||||
/** The name of the host IDE or environment e.g. VSCode, Cursor, IntelliJ Professional Edition, etc. */
|
||||
platform: string
|
||||
/** The version of the host environment */
|
||||
@@ -93,6 +106,8 @@ export type TelemetryMetadata = {
|
||||
is_remote_workspace: boolean
|
||||
/** Whether the extension is running in development mode */
|
||||
is_dev: string | undefined
|
||||
/** Present only in bundles built by the combined legacy/next rollout workflow. */
|
||||
extension_variant?: RolloutTelemetryMetadata["extension_variant"]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,6 +279,8 @@ export class TelemetryService {
|
||||
GEMINI_API_PERFORMANCE: "task.gemini_api_performance",
|
||||
// Tracks when API providers return errors
|
||||
PROVIDER_API_ERROR: "task.provider_api_error",
|
||||
// Tracks when the consecutive mistake limit is reached
|
||||
MISTAKE_LIMIT_REACHED: "task.mistake_limit_reached",
|
||||
// Tracks when users enable the focus chain feature
|
||||
FOCUS_CHAIN_ENABLED: "task.focus_chain_enabled",
|
||||
// Tracks when users disable the focus chain feature
|
||||
@@ -357,6 +374,7 @@ export class TelemetryService {
|
||||
const hostVersion = await HostProvider.env.getHostVersion({})
|
||||
const metadata: TelemetryMetadata = {
|
||||
extension_version: extensionVersion,
|
||||
...(hostVersion.clineVersion ? { host_plugin_version: hostVersion.clineVersion } : {}),
|
||||
platform: hostVersion.platform || "unknown",
|
||||
platform_version: hostVersion.version || "unknown",
|
||||
cline_type: hostVersion.clineType || "unknown",
|
||||
@@ -365,6 +383,7 @@ export class TelemetryService {
|
||||
// `remoteName` is normalized by the host bridge to `undefined` for local workspaces.
|
||||
is_remote_workspace: !!hostVersion.remoteName,
|
||||
is_dev: process.env.IS_DEV,
|
||||
...getRolloutTelemetryMetadata(),
|
||||
}
|
||||
return new TelemetryService(providers, metadata)
|
||||
}
|
||||
@@ -566,6 +585,22 @@ export class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
public captureRolloutBundleActivated(input: RolloutBundleActivation): void {
|
||||
if (!this.telemetryMetadata.extension_variant) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: ROLLOUT_BUNDLE_ACTIVATED_EVENT,
|
||||
properties: {
|
||||
attempted_bundle: input.attemptedBundle,
|
||||
actual_bundle: input.actualBundle,
|
||||
fallback: input.fallback,
|
||||
...(input.fallback ? getRolloutErrorProperties(input.error) : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public captureExtensionStorageError(errorMessage: string, eventName: string) {
|
||||
// Truncate error message to prevent excessive data
|
||||
this.capture({
|
||||
@@ -1364,6 +1399,7 @@ export class TelemetryService {
|
||||
* @param requestId Unique identifier for the specific API request
|
||||
* @param errorMessage Detailed error message from the API provider
|
||||
* @param errorStatus HTTP status code of the error response, if available
|
||||
* @param errorClass Cross-architecture failure class ("context_window_exceeded" | "unknown")
|
||||
* @param collect Optional flag to determine if the event should be collected for batch sending
|
||||
*/
|
||||
public captureProviderApiError(args: {
|
||||
@@ -1373,6 +1409,22 @@ export class TelemetryService {
|
||||
provider?: string
|
||||
errorStatus?: number | undefined
|
||||
requestId?: string | undefined
|
||||
// Mirror the SDK extension's provider-failure schema so the A/B rollout
|
||||
// dashboards can compare cohorts on identical event shapes.
|
||||
// errorType follows ClineErrorType values ("auth", "balance",
|
||||
// "rateLimit", ...); failurePhase follows the SDK extension's
|
||||
// vocabulary ("preflight" | "streaming"). Call sites only report
|
||||
// failures that surfaced to the user (no auto-retry followed) — the
|
||||
// SDK extension can only ever see those, so this keeps the cohorts
|
||||
// comparable without any query-side filtering.
|
||||
errorType?: string | undefined
|
||||
failurePhase?: string | undefined
|
||||
// Failure class shared verbatim with the SDK extension (which attaches
|
||||
// the same property to this event), so one query counts a class across
|
||||
// both rollout cohorts. Orthogonal to errorType: that taxonomy covers
|
||||
// account/transport failures and drives retry control flow, so it is
|
||||
// deliberately not extended with a context-window value.
|
||||
errorClass?: "context_window_exceeded" | "unknown"
|
||||
isNativeToolCall?: boolean
|
||||
}) {
|
||||
this.capture({
|
||||
@@ -1384,22 +1436,61 @@ export class TelemetryService {
|
||||
},
|
||||
})
|
||||
|
||||
// Omit the key entirely when unclassified rather than passing
|
||||
// undefined: the OTel provider stringifies undefined attribute values,
|
||||
// which would bucket unclassified failures under a literal "undefined"
|
||||
// instead of leaving the dimension absent.
|
||||
const errorClassAttribute = args.errorClass ? { error_class: args.errorClass } : {}
|
||||
|
||||
this.recordCounter(TelemetryService.METRICS.ERRORS.TOTAL, 1, {
|
||||
ulid: args.ulid,
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
error_status: args.errorStatus,
|
||||
error_type: args.errorType,
|
||||
failure_phase: args.failurePhase,
|
||||
...errorClassAttribute,
|
||||
})
|
||||
const errorAttributes = {
|
||||
ulid: args.ulid,
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
error_status: args.errorStatus,
|
||||
error_type: args.errorType,
|
||||
failure_phase: args.failurePhase,
|
||||
...errorClassAttribute,
|
||||
}
|
||||
const errorCount = this.incrementTaskCounter(this.taskErrorCounts, args.ulid)
|
||||
this.recordHistogram(TelemetryService.METRICS.ERRORS.PER_TASK, errorCount, errorAttributes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when the consecutive mistake limit is reached, right before the
|
||||
* limit decision (user prompt / yolo auto-stop) is resolved
|
||||
* @param ulid Unique identifier for the task
|
||||
* @param model Identifier of the model used
|
||||
* @param provider Identifier of the API provider
|
||||
* @param consecutiveMistakes Number of consecutive mistakes when the limit was hit
|
||||
* @param maxConsecutiveMistakes The configured mistake limit
|
||||
* @param yoloMode Whether yolo mode auto-failed the task instead of asking the user
|
||||
*/
|
||||
public captureMistakeLimitReached(args: {
|
||||
ulid: string
|
||||
model: string
|
||||
provider?: string
|
||||
consecutiveMistakes: number
|
||||
maxConsecutiveMistakes: number
|
||||
yoloMode?: boolean
|
||||
}) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.MISTAKE_LIMIT_REACHED,
|
||||
properties: {
|
||||
...args,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when focus chain is enabled/disabled by the user
|
||||
* @param enabled Whether focus chain was enabled (true) or disabled (false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import * as assert from "assert"
|
||||
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../providers/ITelemetryProvider"
|
||||
import { ROLLOUT_BUNDLE_ACTIVATED_EVENT, ROLLOUT_ERROR_MESSAGE_LIMIT } from "../rollout-metadata"
|
||||
import { TelemetryMetadata, TelemetryService } from "../TelemetryService"
|
||||
|
||||
class FakeProvider implements ITelemetryProvider {
|
||||
@@ -79,6 +80,61 @@ function createTelemetryService(provider: FakeProvider, overrides: Partial<Telem
|
||||
}
|
||||
|
||||
describe("TelemetryService metrics", () => {
|
||||
it("includes rollout metadata on legacy events and metrics", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider, {
|
||||
extension_variant: "legacy",
|
||||
})
|
||||
|
||||
service.captureTaskCreated("task-rollout", "anthropic")
|
||||
service.captureTokenUsage("task-rollout", 120, 80, "anthropic", "model-a")
|
||||
|
||||
const taskEvent = provider.logs.find((entry) => entry.event === "task.created")
|
||||
assert.strictEqual(taskEvent?.properties?.extension_variant, "legacy")
|
||||
for (const entry of [...provider.counters, ...provider.histograms]) {
|
||||
assert.strictEqual(entry.attributes.extension_variant, "legacy")
|
||||
}
|
||||
})
|
||||
|
||||
it("captures one bounded rollout fallback event for rollout builds", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider, {
|
||||
extension_variant: "legacy",
|
||||
})
|
||||
|
||||
service.captureRolloutBundleActivated({
|
||||
attemptedBundle: "next",
|
||||
actualBundle: "legacy",
|
||||
fallback: true,
|
||||
error: new TypeError("x".repeat(ROLLOUT_ERROR_MESSAGE_LIMIT + 20)),
|
||||
})
|
||||
|
||||
const events = provider.logs.filter((entry) => entry.event === ROLLOUT_BUNDLE_ACTIVATED_EVENT)
|
||||
assert.strictEqual(events.length, 1)
|
||||
assert.strictEqual(events[0].properties?.attempted_bundle, "next")
|
||||
assert.strictEqual(events[0].properties?.actual_bundle, "legacy")
|
||||
assert.strictEqual(events[0].properties?.fallback, true)
|
||||
assert.strictEqual(events[0].properties?.error_type, "TypeError")
|
||||
assert.strictEqual((events[0].properties?.error_message as string).length, ROLLOUT_ERROR_MESSAGE_LIMIT)
|
||||
assert.strictEqual(events[0].properties?.extension_variant, "legacy")
|
||||
})
|
||||
|
||||
it("does not capture rollout activation events for ordinary builds", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider)
|
||||
|
||||
service.captureRolloutBundleActivated({
|
||||
attemptedBundle: "legacy",
|
||||
actualBundle: "legacy",
|
||||
fallback: false,
|
||||
})
|
||||
|
||||
assert.strictEqual(
|
||||
provider.logs.some((entry) => entry.event === ROLLOUT_BUNDLE_ACTIVATED_EVENT),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("captureTokenUsage emits token counters and histograms", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider)
|
||||
@@ -287,8 +343,17 @@ describe("TelemetryService metrics", () => {
|
||||
errorMessage: "boom",
|
||||
provider: "anthropic",
|
||||
errorStatus: 500,
|
||||
errorType: "auth",
|
||||
failurePhase: "streaming",
|
||||
})
|
||||
|
||||
// The event carries the SDK-extension-parity schema fields so the A/B
|
||||
// rollout dashboards can compare cohorts on identical shapes.
|
||||
const failureEvent = provider.logs.find((entry) => entry.event === "task.provider_api_error")
|
||||
assert.ok(failureEvent, "expected task.provider_api_error event")
|
||||
assert.strictEqual(failureEvent?.properties?.errorType, "auth")
|
||||
assert.strictEqual(failureEvent?.properties?.failurePhase, "streaming")
|
||||
|
||||
assert.strictEqual(provider.counters.length, 1)
|
||||
const entry = provider.counters[0]
|
||||
assert.strictEqual(entry.name, TelemetryService.METRICS.ERRORS.TOTAL)
|
||||
@@ -297,6 +362,8 @@ describe("TelemetryService metrics", () => {
|
||||
assert.strictEqual(entry.attributes.provider, "anthropic")
|
||||
assert.strictEqual(entry.attributes.model, "claude")
|
||||
assert.strictEqual(entry.attributes.error_status, 500)
|
||||
assert.strictEqual(entry.attributes.error_type, "auth")
|
||||
assert.strictEqual(entry.attributes.failure_phase, "streaming")
|
||||
assert.strictEqual(provider.histograms.length, 1)
|
||||
const errorHistogram = provider.histograms[0]
|
||||
assert.strictEqual(errorHistogram.name, TelemetryService.METRICS.ERRORS.PER_TASK)
|
||||
@@ -305,6 +372,53 @@ describe("TelemetryService metrics", () => {
|
||||
assert.strictEqual(errorHistogram.attributes.provider, "anthropic")
|
||||
assert.strictEqual(errorHistogram.attributes.model, "claude")
|
||||
assert.strictEqual(errorHistogram.attributes.error_status, 500)
|
||||
assert.strictEqual(errorHistogram.attributes.error_type, "auth")
|
||||
assert.strictEqual(errorHistogram.attributes.failure_phase, "streaming")
|
||||
})
|
||||
|
||||
it("captureProviderApiError carries errorClass on the event and metric attributes", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider)
|
||||
|
||||
service.captureProviderApiError({
|
||||
ulid: "task-overflow",
|
||||
model: "claude",
|
||||
errorMessage: "prompt is too long: 213462 tokens > 200000 maximum",
|
||||
provider: "anthropic",
|
||||
errorType: undefined,
|
||||
failurePhase: "streaming",
|
||||
errorClass: "context_window_exceeded",
|
||||
})
|
||||
|
||||
// errorClass uses the same property name and values as the SDK
|
||||
// extension, so one query counts a class across both cohorts.
|
||||
const failureEvent = provider.logs.find((entry) => entry.event === "task.provider_api_error")
|
||||
assert.ok(failureEvent, "expected task.provider_api_error event")
|
||||
assert.strictEqual(failureEvent?.properties?.errorClass, "context_window_exceeded")
|
||||
assert.strictEqual(provider.counters[0].attributes.error_class, "context_window_exceeded")
|
||||
assert.strictEqual(provider.histograms[0].attributes.error_class, "context_window_exceeded")
|
||||
})
|
||||
|
||||
it("captureProviderApiError omits errorClass when the caller did not classify", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider)
|
||||
|
||||
service.captureProviderApiError({
|
||||
ulid: "task-unclassified",
|
||||
model: "claude",
|
||||
errorMessage: "boom",
|
||||
provider: "anthropic",
|
||||
failurePhase: "streaming",
|
||||
})
|
||||
|
||||
// The key must be absent, not present-and-undefined: the OTel provider
|
||||
// stringifies undefined values, which would create a literal
|
||||
// "undefined" bucket instead of an absent dimension.
|
||||
const failureEvent = provider.logs.find((entry) => entry.event === "task.provider_api_error")
|
||||
assert.ok(failureEvent, "expected task.provider_api_error event")
|
||||
assert.strictEqual("errorClass" in (failureEvent?.properties ?? {}), false)
|
||||
assert.strictEqual("error_class" in provider.counters[0].attributes, false)
|
||||
assert.strictEqual("error_class" in provider.histograms[0].attributes, false)
|
||||
})
|
||||
|
||||
it("captureTaskCompleted records completion payload with TTFT and duration histograms", () => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as assert from "assert"
|
||||
import {
|
||||
getExtensionVariant,
|
||||
getRolloutErrorProperties,
|
||||
getRolloutTelemetryMetadata,
|
||||
ROLLOUT_ERROR_MESSAGE_LIMIT,
|
||||
} from "../rollout-metadata"
|
||||
|
||||
const originalVariant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("CLINE_ROLLOUT_VARIANT", originalVariant)
|
||||
})
|
||||
|
||||
describe("rollout telemetry metadata", () => {
|
||||
it("returns metadata for a rollout build", () => {
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "legacy"
|
||||
|
||||
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {
|
||||
extension_variant: "legacy",
|
||||
})
|
||||
})
|
||||
|
||||
it("omits metadata for ordinary or invalid builds", () => {
|
||||
delete process.env.CLINE_ROLLOUT_VARIANT
|
||||
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {})
|
||||
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "invalid"
|
||||
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {})
|
||||
})
|
||||
|
||||
it("exposes the variant for rollout builds only", () => {
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "legacy"
|
||||
assert.strictEqual(getExtensionVariant(), "legacy")
|
||||
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "next"
|
||||
assert.strictEqual(getExtensionVariant(), "next")
|
||||
|
||||
delete process.env.CLINE_ROLLOUT_VARIANT
|
||||
assert.strictEqual(getExtensionVariant(), undefined)
|
||||
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "invalid"
|
||||
assert.strictEqual(getExtensionVariant(), undefined)
|
||||
})
|
||||
|
||||
it("bounds fallback errors without including stacks", () => {
|
||||
const error = new TypeError("x".repeat(ROLLOUT_ERROR_MESSAGE_LIMIT + 20))
|
||||
const properties = getRolloutErrorProperties(error)
|
||||
|
||||
assert.strictEqual(properties.error_type, "TypeError")
|
||||
assert.strictEqual(properties.error_message.length, ROLLOUT_ERROR_MESSAGE_LIMIT)
|
||||
assert.ok(!properties.error_message.includes("TypeError:"))
|
||||
})
|
||||
})
|
||||
|
||||
function restoreEnv(key: "CLINE_ROLLOUT_VARIANT", value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type ExtensionVariant = "legacy" | "next"
|
||||
|
||||
export interface RolloutTelemetryMetadata {
|
||||
extension_variant: ExtensionVariant
|
||||
}
|
||||
|
||||
export interface RolloutBundleActivation {
|
||||
attemptedBundle: ExtensionVariant
|
||||
actualBundle: ExtensionVariant
|
||||
fallback: boolean
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export const ROLLOUT_BUNDLE_ACTIVATED_EVENT = "extension.rollout.bundle_activated"
|
||||
export const ROLLOUT_ERROR_MESSAGE_LIMIT = 500
|
||||
|
||||
/**
|
||||
* The rollout variant this bundle was built as, or undefined for ordinary builds.
|
||||
* CLINE_ROLLOUT_VARIANT is inlined at build time by the combined rollout workflow only.
|
||||
*/
|
||||
export function getExtensionVariant(): ExtensionVariant | undefined {
|
||||
const variant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
return variant === "legacy" || variant === "next" ? variant : undefined
|
||||
}
|
||||
|
||||
/** Return rollout metadata only for bundles built by the combined rollout workflow. */
|
||||
export function getRolloutTelemetryMetadata(): Partial<RolloutTelemetryMetadata> {
|
||||
const variant = getExtensionVariant()
|
||||
return variant ? { extension_variant: variant } : {}
|
||||
}
|
||||
|
||||
export function getRolloutErrorProperties(error: unknown): {
|
||||
error_type: string
|
||||
error_message: string
|
||||
} {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
error_type: error.name || "Error",
|
||||
error_message: error.message.slice(0, ROLLOUT_ERROR_MESSAGE_LIMIT),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error_type: error === undefined ? "unknown" : error === null ? "null" : typeof error,
|
||||
error_message: (error === undefined ? "Unknown activation error" : String(error)).slice(0, ROLLOUT_ERROR_MESSAGE_LIMIT),
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,11 @@ export interface ExtensionState {
|
||||
lastCompletedCommandTs?: number
|
||||
userInfo?: UserInfo
|
||||
version: string
|
||||
/**
|
||||
* Which rollout bundle this build is ("legacy" or "next"). Only present for
|
||||
* bundles built by the combined rollout workflow; undefined for ordinary builds.
|
||||
*/
|
||||
extensionVariant?: "legacy" | "next"
|
||||
distinctId: string
|
||||
globalClineRulesToggles: ClineRulesToggles
|
||||
localClineRulesToggles: ClineRulesToggles
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { expect } from "chai"
|
||||
import type { ModelInfo } from "../api"
|
||||
import {
|
||||
findPaidClineModelId,
|
||||
formatClineFreeModelName,
|
||||
getClineFreeModelSlug,
|
||||
isClineFreeModelId,
|
||||
resolveClineFreeModelInfo,
|
||||
zeroPricedModelInfo,
|
||||
} from "../cline/free-models"
|
||||
|
||||
describe("Cline free models", () => {
|
||||
describe("isClineFreeModelId", () => {
|
||||
it("matches cline-free ids case-insensitively", () => {
|
||||
expect(isClineFreeModelId("cline-free/deepseek-v4-flash")).to.equal(true)
|
||||
expect(isClineFreeModelId("Cline-Free/GLM-5")).to.equal(true)
|
||||
})
|
||||
|
||||
it("does not match paid or ClinePass ids", () => {
|
||||
expect(isClineFreeModelId("deepseek/deepseek-v4-flash")).to.equal(false)
|
||||
expect(isClineFreeModelId("cline-pass/glm-5.2")).to.equal(false)
|
||||
expect(isClineFreeModelId(undefined)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getClineFreeModelSlug", () => {
|
||||
it("returns the slug after the cline-free prefix", () => {
|
||||
expect(getClineFreeModelSlug("cline-free/deepseek-v4-flash")).to.equal("deepseek-v4-flash")
|
||||
})
|
||||
|
||||
it("returns undefined for non-free ids and empty slugs", () => {
|
||||
expect(getClineFreeModelSlug("deepseek/deepseek-v4-flash")).to.equal(undefined)
|
||||
expect(getClineFreeModelSlug("cline-free/")).to.equal(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatClineFreeModelName", () => {
|
||||
it("appends (free) to free model names", () => {
|
||||
expect(formatClineFreeModelName("cline-free/glm-5", "GLM 5")).to.equal("GLM 5 (free)")
|
||||
})
|
||||
|
||||
it("falls back to the model id when no name is given", () => {
|
||||
expect(formatClineFreeModelName("cline-free/glm-5")).to.equal("cline-free/glm-5 (free)")
|
||||
})
|
||||
|
||||
it("does not double up the (free) marker", () => {
|
||||
expect(formatClineFreeModelName("cline-free/glm-5", "GLM 5 (free)")).to.equal("GLM 5 (free)")
|
||||
})
|
||||
|
||||
it("leaves paid model names untouched", () => {
|
||||
expect(formatClineFreeModelName("z-ai/glm-5", "GLM 5")).to.equal("GLM 5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("zeroPricedModelInfo", () => {
|
||||
it("zeroes every price while preserving capabilities", () => {
|
||||
const info: ModelInfo = {
|
||||
name: "GLM 5",
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.5,
|
||||
outputPrice: 3,
|
||||
cacheReadsPrice: 0.5,
|
||||
cacheWritesPrice: 2,
|
||||
}
|
||||
|
||||
expect(zeroPricedModelInfo(info)).to.deep.equal({
|
||||
...info,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
cacheWritesPrice: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("findPaidClineModelId", () => {
|
||||
const clineModelIds = [
|
||||
"cline-free/deepseek-v4-flash",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"z-ai/glm-5",
|
||||
"anthropic/claude-sonnet-5",
|
||||
]
|
||||
|
||||
it("finds the paid counterpart by model slug", () => {
|
||||
expect(findPaidClineModelId("cline-free/deepseek-v4-flash", clineModelIds)).to.equal("deepseek/deepseek-v4-flash")
|
||||
})
|
||||
|
||||
it("never returns another free model id", () => {
|
||||
expect(findPaidClineModelId("cline-free/deepseek-v4-flash", ["cline-free/deepseek-v4-flash"])).to.equal(undefined)
|
||||
})
|
||||
|
||||
it("returns undefined for non-free ids or when no counterpart exists", () => {
|
||||
expect(findPaidClineModelId("deepseek/deepseek-v4-flash", clineModelIds)).to.equal(undefined)
|
||||
expect(findPaidClineModelId("cline-free/unknown-model", clineModelIds)).to.equal(undefined)
|
||||
expect(findPaidClineModelId(undefined, clineModelIds)).to.equal(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveClineFreeModelInfo", () => {
|
||||
// cline-free/ ids are never published in the models catalog, so capabilities
|
||||
// have to come from the paid twin sharing the slug.
|
||||
const clineModels: Record<string, ModelInfo> = {
|
||||
"deepseek/deepseek-v4-flash": {
|
||||
name: "DeepSeek V4 Flash",
|
||||
maxTokens: 393_216,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.1,
|
||||
cacheWritesPrice: 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
it("borrows the paid twin's capabilities and zeroes the pricing", () => {
|
||||
const info = resolveClineFreeModelInfo("cline-free/deepseek-v4-flash", clineModels)
|
||||
|
||||
expect(info?.contextWindow).to.equal(1_048_576)
|
||||
expect(info?.maxTokens).to.equal(393_216)
|
||||
expect(info?.supportsPromptCache).to.equal(true)
|
||||
expect(info?.inputPrice).to.equal(0)
|
||||
expect(info?.outputPrice).to.equal(0)
|
||||
expect(info?.cacheReadsPrice).to.equal(0)
|
||||
expect(info?.cacheWritesPrice).to.equal(0)
|
||||
expect(info?.name).to.equal("DeepSeek V4 Flash (free)")
|
||||
})
|
||||
|
||||
it("returns undefined when there is no paid twin to borrow from", () => {
|
||||
expect(resolveClineFreeModelInfo("cline-free/unknown-model", clineModels)).to.equal(undefined)
|
||||
expect(resolveClineFreeModelInfo("cline-free/deepseek-v4-flash", {})).to.equal(undefined)
|
||||
expect(resolveClineFreeModelInfo("cline-free/deepseek-v4-flash", undefined)).to.equal(undefined)
|
||||
})
|
||||
|
||||
it("ignores non-free ids", () => {
|
||||
expect(resolveClineFreeModelInfo("deepseek/deepseek-v4-flash", clineModels)).to.equal(undefined)
|
||||
expect(resolveClineFreeModelInfo(undefined, clineModels)).to.equal(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
+367
-46
@@ -129,6 +129,7 @@ export const CLAUDE_SONNET_1M_TIERS = [
|
||||
cacheReadsPrice: 0.6,
|
||||
},
|
||||
]
|
||||
// Claude 4.6+ opus models include the full 1M context window at standard pricing (no long-context premium)
|
||||
export const CLAUDE_OPUS_1M_TIERS = [
|
||||
{
|
||||
contextWindow: 200000,
|
||||
@@ -139,10 +140,10 @@ export const CLAUDE_OPUS_1M_TIERS = [
|
||||
},
|
||||
{
|
||||
contextWindow: Number.MAX_SAFE_INTEGER,
|
||||
inputPrice: 10,
|
||||
outputPrice: 37.5,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1.0,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
},
|
||||
]
|
||||
export const CLAUDE_FABLE_1M_TIERS = [
|
||||
@@ -179,10 +180,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,
|
||||
@@ -312,6 +336,29 @@ export const anthropicModels = {
|
||||
description:
|
||||
"Anthropic fast mode preview for Claude Opus 4.6 with the 1M context beta enabled. Same model and capabilities with higher output token speed at premium pricing across the full 1M context window. Requires both fast mode and 1M context access on your Anthropic account.",
|
||||
},
|
||||
"claude-opus-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
},
|
||||
"claude-opus-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -471,26 +518,26 @@ 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,
|
||||
},
|
||||
opus: {
|
||||
...anthropicModels["claude-opus-4-8"],
|
||||
...anthropicModels["claude-opus-5"],
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"opus[1m]": {
|
||||
...anthropicModels["claude-opus-4-8:1m"],
|
||||
...anthropicModels["claude-opus-5:1m"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
@@ -499,6 +546,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,
|
||||
@@ -534,6 +591,17 @@ export const claudeCodeModels = {
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-5": {
|
||||
...anthropicModels["claude-opus-5"],
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-5[1m]": {
|
||||
...anthropicModels["claude-opus-5:1m"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
...anthropicModels["claude-opus-4-8"],
|
||||
contextWindow: 200_000,
|
||||
@@ -597,8 +665,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,
|
||||
@@ -711,6 +804,31 @@ export const bedrockModels = {
|
||||
cacheReadsPrice: 0.5,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"anthropic.claude-opus-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
},
|
||||
"anthropic.claude-opus-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"anthropic.claude-opus-4-8": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -991,25 +1109,27 @@ 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 openRouterClaudeOpus51mModelId = `anthropic/claude-opus-5${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 +1145,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 +1159,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 +1189,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,19 +1353,46 @@ export const vertexModels = {
|
||||
supportsThinkingLevel: true,
|
||||
},
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
"claude-sonnet-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"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: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-4-6:1m": {
|
||||
maxTokens: 64_000,
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
@@ -1264,6 +1414,19 @@ export const vertexModels = {
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-4@20250514": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -1286,9 +1449,21 @@ export const vertexModels = {
|
||||
cacheReadsPrice: 0.1,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-haiku-4-5": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 1.0,
|
||||
outputPrice: 5.0,
|
||||
cacheWritesPrice: 1.25,
|
||||
cacheReadsPrice: 0.1,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4-6": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
@@ -1297,6 +1472,7 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-6:1m": {
|
||||
maxTokens: 128_000,
|
||||
@@ -1311,9 +1487,9 @@ export const vertexModels = {
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
"claude-opus-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
@@ -1322,6 +1498,33 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-8:1m": {
|
||||
maxTokens: 128_000,
|
||||
@@ -1338,7 +1541,7 @@ export const vertexModels = {
|
||||
},
|
||||
"claude-fable-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
@@ -1347,6 +1550,7 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_FABLE_1M_TIERS,
|
||||
},
|
||||
"claude-fable-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
@@ -1363,7 +1567,7 @@ export const vertexModels = {
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
@@ -1372,6 +1576,7 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-7:1m": {
|
||||
maxTokens: 128_000,
|
||||
@@ -1397,6 +1602,18 @@ export const vertexModels = {
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4-5": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4-1@20250805": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -1408,6 +1625,18 @@ export const vertexModels = {
|
||||
cacheReadsPrice: 1.5,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4-1": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
cacheReadsPrice: 1.5,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4@20250514": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -1705,6 +1934,27 @@ export const vertexGlobalModels: Record<string, ModelInfo> = Object.fromEntries(
|
||||
Object.entries(vertexModels).filter(([_k, v]) => Object.hasOwn(v, "supportsGlobalEndpoint")),
|
||||
) as Record<string, ModelInfo>
|
||||
|
||||
// Defaults for custom (free-form) Vertex model entries. Context window, max output
|
||||
// tokens, image support, and reasoning support are user-editable in settings.
|
||||
export const vertexCustomModelInfoSaneDefaults: ModelInfo = {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsReasoning: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
}
|
||||
|
||||
export function getVertexCustomModelInfo(customModelInfo?: ModelInfo): ModelInfo {
|
||||
return {
|
||||
...vertexCustomModelInfoSaneDefaults,
|
||||
...customModelInfo,
|
||||
// Fixed for all custom Vertex models regardless of user edits.
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
}
|
||||
}
|
||||
|
||||
export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
|
||||
maxTokens: -1,
|
||||
contextWindow: 128_000,
|
||||
@@ -2369,6 +2619,42 @@ export const openAiNativeModels = {
|
||||
export type OpenAiCodexModelId = keyof typeof openAiCodexModels
|
||||
export const openAiCodexDefaultModelId: OpenAiCodexModelId = "gpt-5.3-codex"
|
||||
export const openAiCodexModels = {
|
||||
"gpt-5.6-sol": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 372_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
// Subscription-based: no per-token costs
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "GPT-5.6 Sol: OpenAI's latest frontier agentic coding model via ChatGPT subscription",
|
||||
},
|
||||
"gpt-5.6-terra": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 372_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
// Subscription-based: no per-token costs
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "GPT-5.6 Terra: OpenAI's balanced agentic coding model via ChatGPT subscription",
|
||||
},
|
||||
"gpt-5.6-luna": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 372_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
// Subscription-based: no per-token costs
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "GPT-5.6 Luna: OpenAI's fast agentic coding model via ChatGPT subscription",
|
||||
},
|
||||
"gpt-5.5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
@@ -4360,18 +4646,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 +4666,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,
|
||||
@@ -4695,6 +4988,16 @@ export const sapAiCoreModels = {
|
||||
// Moonshot AI Studio
|
||||
// https://platform.moonshot.ai/docs/pricing/chat
|
||||
export const moonshotModels = {
|
||||
"kimi-k3": {
|
||||
maxTokens: 131_072,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"kimi-k2.6": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 262_144,
|
||||
@@ -4983,12 +5286,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 +5367,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 +5515,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 +5526,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 +5537,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 +5572,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 +5617,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,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
|
||||
/**
|
||||
* Cline free models are exposed by the recommended-models endpoint under a
|
||||
* dedicated namespace (for example `cline-free/deepseek-v4-flash`). They hit the
|
||||
* same Cline API as paid models but are always billed at $0, and they disappear
|
||||
* once their promotion ends.
|
||||
*/
|
||||
export const CLINE_FREE_MODEL_ID_PREFIX = "cline-free/"
|
||||
|
||||
const CLINE_FREE_MODEL_NAME_SUFFIX = " (free)"
|
||||
|
||||
function normalizeModelId(modelId: string): string {
|
||||
return modelId.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isClineFreeModelId(modelId: string | undefined): boolean {
|
||||
return normalizeModelId(modelId ?? "").startsWith(CLINE_FREE_MODEL_ID_PREFIX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model slug of a `cline-free/` id (the part after the prefix), or
|
||||
* undefined when the id is not a Cline free model id.
|
||||
*/
|
||||
export function getClineFreeModelSlug(modelId: string | undefined): string | undefined {
|
||||
if (!isClineFreeModelId(modelId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const modelSlug = (modelId as string).trim().slice(CLINE_FREE_MODEL_ID_PREFIX.length)
|
||||
return modelSlug.length > 0 ? modelSlug : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Free models are explicitly marked in the UI so users can tell them apart from
|
||||
* their paid counterparts, which share the same underlying model slug.
|
||||
*/
|
||||
export function formatClineFreeModelName(modelId: string, name?: string): string {
|
||||
const resolvedName = name?.trim() || modelId
|
||||
if (!isClineFreeModelId(modelId) || resolvedName.toLowerCase().endsWith(CLINE_FREE_MODEL_NAME_SUFFIX)) {
|
||||
return resolvedName
|
||||
}
|
||||
|
||||
return `${resolvedName}${CLINE_FREE_MODEL_NAME_SUFFIX}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Free models ride usage billing at $0, so never display or accumulate cost for them.
|
||||
*/
|
||||
export function zeroPricedModelInfo(info: ModelInfo): ModelInfo {
|
||||
return {
|
||||
...info,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
cacheWritesPrice: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveClineFreeModelInfo(
|
||||
freeModelId: string | undefined,
|
||||
clineModels: Record<string, ModelInfo> | undefined | null,
|
||||
): ModelInfo | undefined {
|
||||
if (!isClineFreeModelId(freeModelId) || !clineModels) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const paidModelId = findPaidClineModelId(freeModelId, Object.keys(clineModels))
|
||||
const paidModelInfo = paidModelId ? clineModels[paidModelId] : undefined
|
||||
if (!paidModelInfo) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return zeroPricedModelInfo({
|
||||
...paidModelInfo,
|
||||
name: formatClineFreeModelName(freeModelId as string, paidModelInfo.name),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Free model ids are `cline-free/<model-slug>`; their paid counterpart is the
|
||||
* catalog model with the same slug under its lab prefix (for example
|
||||
* `cline-free/deepseek-v4-flash` -> `deepseek/deepseek-v4-flash`).
|
||||
*/
|
||||
export function findPaidClineModelId(freeModelId: string | undefined, clineModelIds: string[]): string | undefined {
|
||||
const modelSlug = getClineFreeModelSlug(freeModelId)
|
||||
if (!modelSlug) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizedModelSlug = normalizeModelId(modelSlug)
|
||||
return clineModelIds.find((modelId) => {
|
||||
if (isClineFreeModelId(modelId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedModelId = normalizeModelId(modelId)
|
||||
return normalizedModelId === normalizedModelSlug || normalizedModelId.endsWith(`/${normalizedModelSlug}`)
|
||||
})
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ function convertModelInfoToProtoOpenRouter(info: ModelInfo | undefined): OpenRou
|
||||
contextWindow: info.contextWindow,
|
||||
supportsImages: info.supportsImages,
|
||||
supportsPromptCache: info.supportsPromptCache ?? false,
|
||||
supportsReasoning: info.supportsReasoning,
|
||||
inputPrice: info.inputPrice,
|
||||
outputPrice: info.outputPrice,
|
||||
cacheWritesPrice: info.cacheWritesPrice,
|
||||
@@ -77,6 +78,7 @@ function convertProtoToModelInfo(info: OpenRouterModelInfo | undefined): ModelIn
|
||||
contextWindow: info.contextWindow,
|
||||
supportsImages: info.supportsImages,
|
||||
supportsPromptCache: info.supportsPromptCache,
|
||||
supportsReasoning: info.supportsReasoning,
|
||||
inputPrice: info.inputPrice,
|
||||
outputPrice: info.outputPrice,
|
||||
cacheWritesPrice: info.cacheWritesPrice,
|
||||
@@ -528,6 +530,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
planModeVsCodeLmModelSelector: config.planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected: config.planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId: config.planModeAwsBedrockCustomModelBaseId as string | undefined,
|
||||
planModeVertexCustomModelSelected: config.planModeVertexCustomModelSelected,
|
||||
planModeVertexCustomModelInfo: convertModelInfoToProtoOpenRouter(config.planModeVertexCustomModelInfo),
|
||||
planModeOpenRouterModelId: config.planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.planModeOpenRouterModelInfo),
|
||||
planModeClineModelId: config.planModeClineModelId,
|
||||
@@ -574,6 +578,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
actModeVsCodeLmModelSelector: config.actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected: config.actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId: config.actModeAwsBedrockCustomModelBaseId as string | undefined,
|
||||
actModeVertexCustomModelSelected: config.actModeVertexCustomModelSelected,
|
||||
actModeVertexCustomModelInfo: convertModelInfoToProtoOpenRouter(config.actModeVertexCustomModelInfo),
|
||||
actModeOpenRouterModelId: config.actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.actModeOpenRouterModelInfo),
|
||||
actModeClineModelId: config.actModeClineModelId,
|
||||
@@ -715,6 +721,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
planModeVsCodeLmModelSelector: protoConfig.planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected: protoConfig.planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId: protoConfig.planModeAwsBedrockCustomModelBaseId as BedrockModelId | undefined,
|
||||
planModeVertexCustomModelSelected: protoConfig.planModeVertexCustomModelSelected,
|
||||
planModeVertexCustomModelInfo: convertProtoToModelInfo(protoConfig.planModeVertexCustomModelInfo),
|
||||
planModeOpenRouterModelId: protoConfig.planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.planModeOpenRouterModelInfo),
|
||||
planModeClineModelId: protoConfig.planModeClineModelId,
|
||||
@@ -762,6 +770,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
actModeVsCodeLmModelSelector: protoConfig.actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected: protoConfig.actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId: protoConfig.actModeAwsBedrockCustomModelBaseId as BedrockModelId | undefined,
|
||||
actModeVertexCustomModelSelected: protoConfig.actModeVertexCustomModelSelected,
|
||||
actModeVertexCustomModelInfo: convertProtoToModelInfo(protoConfig.actModeVertexCustomModelInfo),
|
||||
actModeOpenRouterModelId: protoConfig.actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.actModeOpenRouterModelInfo),
|
||||
actModeClineModelId: protoConfig.actModeClineModelId,
|
||||
|
||||
@@ -152,6 +152,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
|
||||
planModeVsCodeLmModelSelector: { default: undefined as LanguageModelChatSelector | undefined },
|
||||
planModeAwsBedrockCustomSelected: { default: undefined as boolean | undefined },
|
||||
planModeAwsBedrockCustomModelBaseId: { default: undefined as string | undefined },
|
||||
planModeVertexCustomModelSelected: { default: undefined as boolean | undefined },
|
||||
planModeVertexCustomModelInfo: { default: undefined as ModelInfo | undefined },
|
||||
planModeOpenRouterModelId: { default: undefined as string | undefined },
|
||||
planModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
|
||||
planModeClineModelId: { default: undefined as string | undefined },
|
||||
@@ -198,6 +200,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
|
||||
actModeVsCodeLmModelSelector: { default: undefined as LanguageModelChatSelector | undefined },
|
||||
actModeAwsBedrockCustomSelected: { default: undefined as boolean | undefined },
|
||||
actModeAwsBedrockCustomModelBaseId: { default: undefined as string | undefined },
|
||||
actModeVertexCustomModelSelected: { default: undefined as boolean | undefined },
|
||||
actModeVertexCustomModelInfo: { default: undefined as ModelInfo | undefined },
|
||||
actModeOpenRouterModelId: { default: undefined as string | undefined },
|
||||
actModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
|
||||
actModeClineModelId: { default: undefined as string | undefined },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { isClineFreeModelId } from "@shared/cline/free-models"
|
||||
|
||||
function normalizeModelId(modelId: string): string {
|
||||
return modelId.trim().toLowerCase()
|
||||
@@ -13,8 +14,9 @@ export function isClineFreeModelException(modelId: string): boolean {
|
||||
|
||||
/**
|
||||
* Filters OpenRouter model IDs based on provider-specific rules.
|
||||
* For Cline provider: excludes :free models (except known exception models)
|
||||
* For OpenRouter/Vercel: excludes cline/ prefixed models
|
||||
* For Cline provider: excludes :free models (except known exception models and
|
||||
* explicit cline-free/ models)
|
||||
* For OpenRouter/Vercel: excludes cline/ prefixed models (including cline-free/)
|
||||
* @param modelIds Array of model IDs to filter
|
||||
* @param provider The current API provider
|
||||
* @param allowedFreeModelIds Optional list of Cline free model IDs to keep visible
|
||||
@@ -33,6 +35,10 @@ export function filterOpenRouterModelIds(
|
||||
if (allowedFreeIdSet.has(normalizedModelId)) {
|
||||
return true
|
||||
}
|
||||
// Explicit Cline free models are always selectable on the Cline routes
|
||||
if (isClineFreeModelId(normalizedModelId)) {
|
||||
return true
|
||||
}
|
||||
if (isClineFreeModelException(normalizedModelId)) {
|
||||
return true
|
||||
}
|
||||
@@ -42,5 +48,5 @@ export function filterOpenRouterModelIds(
|
||||
}
|
||||
|
||||
// For OpenRouter and Vercel AI Gateway providers: exclude Cline-specific models
|
||||
return modelIds.filter((id) => !id.startsWith("cline/"))
|
||||
return modelIds.filter((id) => !id.startsWith("cline/") && !isClineFreeModelId(id))
|
||||
}
|
||||
|
||||
@@ -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,20 @@ 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") ||
|
||||
id.includes("claude-opus-5") ||
|
||||
id.includes("claude-5-opus") ||
|
||||
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 +38,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 +47,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"],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChildProcess } from "node:child_process"
|
||||
import { mkdtempSync, type PathLike, type RmOptions, readdirSync, rmSync } from "node:fs"
|
||||
import { existsSync, mkdtempSync, type PathLike, type RmOptions, readdirSync, rmSync } from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { type ElectronApplication, expect, type Frame, type Page, test } from "@playwright/test"
|
||||
@@ -61,6 +61,37 @@ export class E2ETestHelper {
|
||||
return `${baseName}${projectSuffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the real VS Code executable for `_electron.launch()`.
|
||||
*
|
||||
* `@vscode/test-electron` hardcodes the macOS entry point as
|
||||
* `Visual Studio Code.app/Contents/MacOS/Electron`, but recent VS Code builds
|
||||
* ship that binary as `Code` instead, so launching fails with ENOENT.
|
||||
* See https://github.com/microsoft/vscode-test/issues/355.
|
||||
*
|
||||
* When the reported path is missing, fall back to the bundle's real executable
|
||||
* name(s) so E2E works on both the old and new layouts.
|
||||
*/
|
||||
public static resolveVSCodeExecutablePath(executablePath: string): string {
|
||||
if (existsSync(executablePath)) {
|
||||
return executablePath
|
||||
}
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
return executablePath
|
||||
}
|
||||
|
||||
const macOSDir = path.dirname(executablePath)
|
||||
for (const candidate of ["Code", "Code - Insiders"]) {
|
||||
const candidatePath = path.join(macOSDir, candidate)
|
||||
if (existsSync(candidatePath)) {
|
||||
return candidatePath
|
||||
}
|
||||
}
|
||||
|
||||
return executablePath
|
||||
}
|
||||
|
||||
public static async waitUntil(predicate: () => boolean | Promise<boolean>, maxDelay = 10000): Promise<void> {
|
||||
let delay = 10
|
||||
const start = Date.now()
|
||||
@@ -373,7 +404,9 @@ export const e2e = test
|
||||
})
|
||||
.extend<{ openVSCode: (workspacePath: string) => Promise<ElectronApplication> }>({
|
||||
openVSCode: async ({ userDataDir, channel }, use, testInfo) => {
|
||||
const executablePath = await downloadAndUnzipVSCode(channel, undefined, new SilentReporter())
|
||||
const executablePath = E2ETestHelper.resolveVSCodeExecutablePath(
|
||||
await downloadAndUnzipVSCode(channel, undefined, new SilentReporter()),
|
||||
)
|
||||
|
||||
await use(async (workspacePath: string) => {
|
||||
// Create isolated Cline data directory for this test
|
||||
|
||||
@@ -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,11 +1,13 @@
|
||||
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,
|
||||
isGeminiFlashModel,
|
||||
isGLMModelFamily,
|
||||
isGPT51PlusModel,
|
||||
isGPT5ModelFamily,
|
||||
isGptOssModelFamily,
|
||||
isNativeToolCallingConfig,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
isPoolsideModelFamily,
|
||||
modelDoesntSupportWebp,
|
||||
shouldSkipReasoningForModel,
|
||||
supportsReasoningEffortForModel,
|
||||
} from "../model-utils"
|
||||
|
||||
// Minimal helper — modelDoesntSupportWebp only reads apiHandlerModel.id
|
||||
@@ -37,6 +40,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 +54,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 +106,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)
|
||||
@@ -105,6 +146,14 @@ describe("isGPT5ModelFamily", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGPT51PlusModel", () => {
|
||||
it("should recognize GPT-5.6 model ID variants", () => {
|
||||
isGPT51PlusModel("gpt-5.6-sol").should.equal(true)
|
||||
isGPT51PlusModel("gpt-5-6-terra").should.equal(true)
|
||||
isGPT51PlusModel("openai/gpt-5.6-luna").should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGptOssModelFamily", () => {
|
||||
it("should return true for gpt-oss model IDs", () => {
|
||||
isGptOssModelFamily("gpt-oss-120b").should.equal(true)
|
||||
@@ -142,6 +191,14 @@ describe("isPoolsideModelFamily", () => {
|
||||
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), true).should.equal(true)
|
||||
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), false).should.equal(false)
|
||||
})
|
||||
|
||||
it("should qualify Kimi K2 and K3 models for next-gen and native tool calling paths", () => {
|
||||
for (const modelId of ["moonshotai/kimi-k2", "kimi-k2-thinking", "moonshotai/kimi-k3", "kimi-k3"]) {
|
||||
isNextGenModelFamily(modelId).should.equal(true)
|
||||
isNativeToolCallingConfig(providerInfo("openrouter", modelId), true).should.equal(true)
|
||||
isNativeToolCallingConfig(providerInfo("cline", modelId), true).should.equal(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGeminiFlashModel", () => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -115,7 +115,9 @@ export function isGPT51PlusModel(id: string): boolean {
|
||||
modelId.includes("gpt-5.4") ||
|
||||
modelId.includes("gpt-5-4") ||
|
||||
modelId.includes("gpt-5.5") ||
|
||||
modelId.includes("gpt-5-5")
|
||||
modelId.includes("gpt-5-5") ||
|
||||
modelId.includes("gpt-5.6") ||
|
||||
modelId.includes("gpt-5-6")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,7 +156,7 @@ export function isHermesModelFamily(id: string): boolean {
|
||||
|
||||
export function isNextGenOpenSourceModelFamily(id: string): boolean {
|
||||
const modelId = normalize(id)
|
||||
return ["kimi-k2"].some((substring) => modelId.includes(substring))
|
||||
return ["kimi-k2", "kimi-k3"].some((substring) => modelId.includes(substring))
|
||||
}
|
||||
|
||||
export function isDevstralModelFamily(id: string): boolean {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getClineFreeModelSlug, isClineFreeModelId } from "@shared/cline/free-models"
|
||||
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { FileSearchRequest, FileSearchType, RelativePathsRequest } from "@shared/proto/cline/file"
|
||||
@@ -1105,7 +1106,20 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
}
|
||||
switch (selectedProvider) {
|
||||
case "cline":
|
||||
return `${selectedProvider}:${selectedModelId}`
|
||||
// cline-free/ ids already carry their own namespace, so don't double
|
||||
// up the provider prefix in the badge
|
||||
return isClineFreeModelId(selectedModelId)
|
||||
? `cline:${getClineFreeModelSlug(selectedModelId)} (free)`
|
||||
: `${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
|
||||
if (selectedModelId.startsWith("cline-pass/")) {
|
||||
return `${selectedProvider}:${selectedModelId.replace(/^cline-pass\//, "")}`
|
||||
}
|
||||
return isClineFreeModelId(selectedModelId)
|
||||
? `cline:${getClineFreeModelSlug(selectedModelId)} (free)`
|
||||
: `cline:${selectedModelId}`
|
||||
case "openai":
|
||||
return `openai-compat:${selectedModelId}`
|
||||
case "vscode-lm":
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { openRouterDefaultModelInfo } from "@shared/api"
|
||||
import { findPaidClineModelId } from "@shared/cline/free-models"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { getModeSpecificFields } from "@/components/settings/utils/providerUtils"
|
||||
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { extractClineFreeModelLimitResetTime } from "../../../../src/services/error/ClineError"
|
||||
|
||||
interface ClineFreeModelLimitErrorProps {
|
||||
message: string
|
||||
}
|
||||
|
||||
const CLINE_PROVIDER_ID = "cline"
|
||||
|
||||
const ClineFreeModelLimitError = ({ message }: ClineFreeModelLimitErrorProps) => {
|
||||
const { apiConfiguration, mode, clineModels } = useExtensionState()
|
||||
const { handleModeFieldsChange } = useApiConfigurationHandlers()
|
||||
const [isSwitching, setIsSwitching] = useState(false)
|
||||
const [didSwitch, setDidSwitch] = useState(false)
|
||||
const [switchError, setSwitchError] = useState<string | undefined>()
|
||||
|
||||
const resetTime = extractClineFreeModelLimitResetTime(message)
|
||||
const currentMode: Mode = mode ?? "act"
|
||||
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
|
||||
// Free models are selectable on both the cline and cline-pass providers, so
|
||||
// read the model id from whichever provider is currently selected.
|
||||
const selectedFreeModelId =
|
||||
modeFields.apiProvider === "cline-pass"
|
||||
? modeFields.clinePassModelId
|
||||
: modeFields.apiProvider === CLINE_PROVIDER_ID
|
||||
? modeFields.clineModelId
|
||||
: undefined
|
||||
const paidModelId = useMemo(
|
||||
() => findPaidClineModelId(selectedFreeModelId, Object.keys(clineModels ?? {})),
|
||||
[selectedFreeModelId, clineModels],
|
||||
)
|
||||
|
||||
const handleSwitchToPaidModel = async () => {
|
||||
if (!paidModelId) {
|
||||
return
|
||||
}
|
||||
setIsSwitching(true)
|
||||
setSwitchError(undefined)
|
||||
try {
|
||||
const modelInfo = clineModels?.[paidModelId] ?? {
|
||||
...openRouterDefaultModelInfo,
|
||||
name: paidModelId,
|
||||
}
|
||||
|
||||
await handleModeFieldsChange(
|
||||
{
|
||||
apiProvider: {
|
||||
plan: "planModeApiProvider",
|
||||
act: "actModeApiProvider",
|
||||
},
|
||||
clineModelId: {
|
||||
plan: "planModeClineModelId",
|
||||
act: "actModeClineModelId",
|
||||
},
|
||||
clineModelInfo: {
|
||||
plan: "planModeClineModelInfo",
|
||||
act: "actModeClineModelInfo",
|
||||
},
|
||||
},
|
||||
{
|
||||
apiProvider: CLINE_PROVIDER_ID,
|
||||
clineModelId: paidModelId,
|
||||
clineModelInfo: modelInfo,
|
||||
},
|
||||
currentMode,
|
||||
)
|
||||
setDidSwitch(true)
|
||||
} catch (error) {
|
||||
console.error("Failed to switch to the paid model:", error)
|
||||
setSwitchError(`Failed to switch model. Select ${paidModelId} 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-free-model-limit-error">
|
||||
<div className="text-error mb-2">Daily free model limit reached</div>
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
|
||||
You've reached today's free usage limit for this model.
|
||||
</div>
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
|
||||
{resetTime ? `Try again in ${resetTime}` : "Try again later"} or select another model.
|
||||
</div>
|
||||
{paidModelId && (
|
||||
<>
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs mt-2 wrap-anywhere">
|
||||
Or switch to the paid version of this model ({paidModelId}) with usage-based billing.
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
className="w-full mt-3"
|
||||
disabled={isSwitching || didSwitch}
|
||||
onClick={handleSwitchToPaidModel}>
|
||||
{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>
|
||||
)}
|
||||
{switchError && <div className="text-error text-xs mt-2">{switchError}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClineFreeModelLimitError
|
||||
@@ -0,0 +1,37 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext";
|
||||
|
||||
// Shown when a request targets a retired cline-free/ model: once its free
|
||||
// promotion ends the model is removed from the catalog and the backend answers
|
||||
// "model not found". Mirrors the CLI's "Free model promotion ended" banner,
|
||||
// with the CLI's "/model" hint replaced by a button into the model picker.
|
||||
const ClineFreePromotionEndedError = () => {
|
||||
const { navigateToSettingsModelPicker } = useExtensionState();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)"
|
||||
data-testid="cline-free-promotion-ended-error"
|
||||
>
|
||||
<div className="text-error mb-2">Free model promotion ended</div>
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
|
||||
The free promotion for this model has ended and it is no longer
|
||||
available.
|
||||
</div>
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
|
||||
Select another model to continue.
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
className="w-full mt-3"
|
||||
onClick={() =>
|
||||
navigateToSettingsModelPicker({ targetSection: "api-config" })
|
||||
}
|
||||
>
|
||||
Select a Model
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClineFreePromotionEndedError;
|
||||
@@ -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,30 @@
|
||||
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 mockNavigateToSettingsModelPicker = 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",
|
||||
}));
|
||||
// The free-model limit card offers the paid twin of the selected free model, which
|
||||
// it resolves out of the Cline catalog by model slug.
|
||||
const mockClineModels = vi.hoisted(() => ({
|
||||
"deepseek/deepseek-v4-flash": {
|
||||
name: "DeepSeek V4 Flash",
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the auth context
|
||||
vi.mock("@/context/ClineAuthContext", () => ({
|
||||
@@ -30,10 +51,22 @@ vi.mock("@/components/chat/EntitlementError", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: () => ({
|
||||
apiConfiguration: mockApiConfiguration,
|
||||
mode: "act",
|
||||
clineModels: mockClineModels,
|
||||
navigateToSettingsModelPicker: mockNavigateToSettingsModelPicker,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/grpc-client", () => ({
|
||||
AccountServiceClient: {
|
||||
setUserOrganization: mockSetUserOrganization,
|
||||
},
|
||||
ModelsServiceClient: {
|
||||
updateApiConfigurationProto: mockUpdateApiConfigurationProto,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ClineError
|
||||
@@ -47,7 +80,19 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
|
||||
Auth: "auth",
|
||||
Entitlement: "entitlement",
|
||||
OrgClinePassRestriction: "orgClinePassRestriction",
|
||||
ClinePassLimit: "clinePassLimit",
|
||||
ClineFreeModelLimit: "clineFreeModelLimit",
|
||||
ClineFreePromotionEnded: "clineFreePromotionEnded",
|
||||
},
|
||||
extractClinePassLimitMessage: vi.fn((text: string) => text),
|
||||
extractClineFreeModelLimitResetTime: vi.fn((text: string) => {
|
||||
const marker = "try again in ";
|
||||
const start = text.toLowerCase().indexOf(marker);
|
||||
if (start === -1) {
|
||||
return undefined;
|
||||
}
|
||||
return text.slice(start + marker.length).trim() || undefined;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ErrorRow", () => {
|
||||
@@ -61,6 +106,7 @@ describe("ErrorRow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSetUserOrganization.mockResolvedValue({});
|
||||
mockUpdateApiConfigurationProto.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("renders basic error message", () => {
|
||||
@@ -266,6 +312,212 @@ 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 a daily free model limit without usage-billing guidance", async () => {
|
||||
const limitMessage =
|
||||
"Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m";
|
||||
const mockClineError = {
|
||||
message: limitMessage,
|
||||
isErrorType: vi.fn((type) => type === "clineFreeModelLimit"),
|
||||
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.getByTestId("cline-free-model-limit-error"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/You've reached today's free usage limit for this model/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Try again in 23h 59m/)).toBeInTheDocument();
|
||||
// The raw backend message (including the model id) is replaced by the copy above
|
||||
expect(screen.queryByText(limitMessage)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Switch to Usage-Based billing/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the promotion-ended card with a route into the model picker", async () => {
|
||||
const rawMessage = "Error 404: Model not found";
|
||||
const mockClineError = {
|
||||
message: rawMessage,
|
||||
isErrorType: vi.fn((type) => type === "clineFreePromotionEnded"),
|
||||
providerId: "cline",
|
||||
modelId: "cline-free/glm-5",
|
||||
_error: { message: rawMessage },
|
||||
};
|
||||
|
||||
const { ClineError } = await import(
|
||||
"../../../../src/services/error/ClineError"
|
||||
);
|
||||
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
|
||||
|
||||
render(
|
||||
<ErrorRow
|
||||
apiRequestFailedMessage={rawMessage}
|
||||
errorType="error"
|
||||
message={mockMessage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("cline-free-promotion-ended-error"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Free model promotion ended"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/no longer\s+available/)).toBeInTheDocument();
|
||||
// The raw backend message is replaced by the dedicated copy.
|
||||
expect(screen.queryByText(rawMessage)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Select a Model"));
|
||||
expect(mockNavigateToSettingsModelPicker).toHaveBeenCalledWith({
|
||||
targetSection: "api-config",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers the paid twin of the selected free model", async () => {
|
||||
const limitMessage =
|
||||
"Daily free limit reached on model cline-free/deepseek-v4-flash. Try again in 23h 59m";
|
||||
const mockClineError = {
|
||||
message: limitMessage,
|
||||
isErrorType: vi.fn((type) => type === "clineFreeModelLimit"),
|
||||
providerId: "cline",
|
||||
_error: { message: limitMessage },
|
||||
};
|
||||
|
||||
const { ClineError } = await import(
|
||||
"../../../../src/services/error/ClineError"
|
||||
);
|
||||
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
|
||||
// The configured provider drives which model id the card reads
|
||||
mockApiConfiguration.actModeApiProvider = "cline";
|
||||
(mockApiConfiguration as Record<string, unknown>).actModeClineModelId =
|
||||
"cline-free/deepseek-v4-flash";
|
||||
|
||||
try {
|
||||
render(
|
||||
<ErrorRow
|
||||
apiRequestFailedMessage={limitMessage}
|
||||
errorType="error"
|
||||
message={mockMessage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/deepseek\/deepseek-v4-flash/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Switch to Usage-Based billing"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateApiConfigurationProto).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const request = mockUpdateApiConfigurationProto.mock.calls[0][0];
|
||||
expect(request.apiConfiguration.actModeApiProvider).toBe(
|
||||
ApiProvider.CLINE,
|
||||
);
|
||||
expect(request.apiConfiguration.actModeClineModelId).toBe(
|
||||
"deepseek/deepseek-v4-flash",
|
||||
);
|
||||
} finally {
|
||||
mockApiConfiguration.actModeApiProvider = "cline-pass";
|
||||
delete (mockApiConfiguration as Record<string, unknown>)
|
||||
.actModeClineModelId;
|
||||
}
|
||||
});
|
||||
|
||||
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,9 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage";
|
||||
import { isClineProvider } from "@shared/utils/cline";
|
||||
import { memo } from "react";
|
||||
import ClineFreeModelLimitError from "@/components/chat/ClineFreeModelLimitError";
|
||||
import ClineFreePromotionEndedError from "@/components/chat/ClineFreePromotionEndedError";
|
||||
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 +13,7 @@ import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext";
|
||||
import {
|
||||
ClineError,
|
||||
ClineErrorType,
|
||||
extractClinePassLimitMessage,
|
||||
} from "../../../../src/services/error/ClineError";
|
||||
|
||||
const _errorColor = "var(--vscode-errorForeground)";
|
||||
@@ -90,6 +94,37 @@ 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} />;
|
||||
}
|
||||
|
||||
// Daily free-model limits have their own remedy (wait for the
|
||||
// reset, pick another model, or switch to the paid twin), so
|
||||
// they get dedicated copy instead of the raw backend message.
|
||||
if (clineError?.isErrorType(ClineErrorType.ClineFreeModelLimit)) {
|
||||
const detailMessage =
|
||||
clineError?._error?.details?.message || errorMessage;
|
||||
return <ClineFreeModelLimitError message={detailMessage} />;
|
||||
}
|
||||
|
||||
// A retired free model answers model-not-found once its
|
||||
// promotion ends — dedicated copy plus a route into the model
|
||||
// picker, since retrying the deleted model can never succeed.
|
||||
if (
|
||||
clineError?.isErrorType(ClineErrorType.ClineFreePromotionEnded)
|
||||
) {
|
||||
return <ClineFreePromotionEndedError />;
|
||||
}
|
||||
|
||||
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
|
||||
return (
|
||||
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
|
||||
|
||||
+74
-6
@@ -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>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type ApiConfiguration, buildModelInfoNameMap, CLAUDE_SONNET_1M_SUFFIX, type ModelInfo } from "@shared/api"
|
||||
import {
|
||||
formatClineFreeModelName,
|
||||
isClineFreeModelId,
|
||||
resolveClineFreeModelInfo,
|
||||
zeroPricedModelInfo,
|
||||
} from "@shared/cline/free-models"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { type ClineRecommendedModel, ClineRecommendedModelsResponse } from "@shared/proto/cline/models"
|
||||
@@ -47,6 +53,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 +71,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 +90,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 +127,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 +169,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,13 +250,36 @@ 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)
|
||||
|
||||
const newModelInfo = resolvedModels?.[newModelId]
|
||||
// cline-free/ ids are not published in the models catalog, so borrow the paid
|
||||
// twin's capabilities. Without this the handler would fall back to
|
||||
// openRouterDefaultModelInfo and use Claude Sonnet's limits for another model.
|
||||
const freeModelInfo = isClineFreeModelId(newModelId)
|
||||
? (newModelInfo ?? resolveClineFreeModelInfo(newModelId, resolvedModels))
|
||||
: undefined
|
||||
|
||||
handleModeFieldsChange(
|
||||
{
|
||||
clineModelId: modelIdFieldPair,
|
||||
@@ -244,7 +287,14 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
|
||||
},
|
||||
{
|
||||
clineModelId: newModelId,
|
||||
clineModelInfo: resolvedModels?.[newModelId],
|
||||
// Free models ride usage billing at $0, so persist them zero-priced and
|
||||
// with the explicit "(free)" name so cost UI never shows paid rates.
|
||||
clineModelInfo: freeModelInfo
|
||||
? zeroPricedModelInfo({
|
||||
...freeModelInfo,
|
||||
name: formatClineFreeModelName(newModelId, freeModelInfo.name),
|
||||
})
|
||||
: newModelInfo,
|
||||
},
|
||||
currentMode,
|
||||
)
|
||||
@@ -260,16 +310,22 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
|
||||
selectedModelInfo: resolvedModels?.[resolvedModelId] ?? normalizedSelection.selectedModelInfo,
|
||||
}
|
||||
: normalizedSelection
|
||||
if (freeClineModelIdSet.has(normalizeModelId(selected.selectedModelId))) {
|
||||
// Explicit cline-free/ ids are free even before the recommended-models
|
||||
// response lands, so check the namespace as well as the fetched free list.
|
||||
if (isClineFreeModelId(selected.selectedModelId) || freeClineModelIdSet.has(normalizeModelId(selected.selectedModelId))) {
|
||||
// cline-free/ ids are absent from the catalog, so normalizeApiConfiguration
|
||||
// can only supply the default info. Prefer the paid twin's real capabilities
|
||||
// so the info panel and context-window UI don't show Claude Sonnet's limits.
|
||||
const freeModelInfo =
|
||||
resolvedModels?.[selected.selectedModelId] ??
|
||||
resolveClineFreeModelInfo(selected.selectedModelId, resolvedModels) ??
|
||||
selected.selectedModelInfo
|
||||
return {
|
||||
...selected,
|
||||
selectedModelInfo: {
|
||||
...selected.selectedModelInfo,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
cacheWritesPrice: 0,
|
||||
},
|
||||
selectedModelInfo: zeroPricedModelInfo({
|
||||
...freeModelInfo,
|
||||
name: formatClineFreeModelName(selected.selectedModelId, freeModelInfo?.name),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return selected
|
||||
@@ -414,6 +470,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 +500,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 +673,22 @@ 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 5 */}
|
||||
<ContextWindowSwitcher
|
||||
base1mModelId={`anthropic/claude-opus-5${CLAUDE_SONNET_1M_SUFFIX}`}
|
||||
base200kModelId="anthropic/claude-opus-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 +773,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,22 @@ 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 5 */}
|
||||
<ContextWindowSwitcher
|
||||
base1mModelId={`anthropic/claude-opus-5${CLAUDE_SONNET_1M_SUFFIX}`}
|
||||
base200kModelId="anthropic/claude-opus-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 +439,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>
|
||||
|
||||
@@ -164,7 +164,8 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
[],
|
||||
); // Empty deps - these imports never change
|
||||
|
||||
const { version, environment, settingsInitialModelTab } = useExtensionState();
|
||||
const { version, extensionVariant, environment, settingsInitialModelTab } =
|
||||
useExtensionState();
|
||||
const { activeOrganization, clineUser } = useClineAuth();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
@@ -278,6 +279,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
props.onResetState = handleResetState;
|
||||
} else if (activeTab === "about") {
|
||||
props.version = version;
|
||||
props.extensionVariant = extensionVariant;
|
||||
} else if (activeTab === "api-config") {
|
||||
props.initialModelTab = settingsInitialModelTab;
|
||||
}
|
||||
@@ -288,6 +290,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
handleResetState,
|
||||
settingsInitialModelTab,
|
||||
version,
|
||||
extensionVariant,
|
||||
TAB_CONTENT_MAP,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user