mirror of
https://github.com/cline/cline.git
synced 2026-09-06 12:28:08 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 864419d8b0 |
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- legacy-extension
|
||||
workflow_call:
|
||||
|
||||
# Set default permissions for all jobs
|
||||
|
||||
+100
@@ -1,5 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## [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.11",
|
||||
"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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -523,8 +524,7 @@ export class Controller {
|
||||
|
||||
// On login we route the user to the managed "cline" provider, but preserve a
|
||||
// "cline-pass" selection made during onboarding (otherwise it would be clobbered).
|
||||
// A "cline-pass" provider can only be set when the ext-cline-pass flag is on, so
|
||||
// non-ClinePass logins are unaffected.
|
||||
// Non-ClinePass logins are unaffected because they do not persist "cline-pass".
|
||||
const planProvider: ApiProvider =
|
||||
currentApiConfiguration.planModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
|
||||
const actProvider: ApiProvider = currentApiConfiguration.actModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
|
||||
@@ -856,7 +856,7 @@ export class Controller {
|
||||
const taskHistory = this.stateManager.getGlobalStateKey("taskHistory")
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
|
||||
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
|
||||
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings") ?? DEFAULT_FOCUS_CHAIN_SETTINGS
|
||||
const preferredLanguage = this.stateManager.getGlobalSettingsKey("preferredLanguage")
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode")
|
||||
const strictPlanModeEnabled = this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled")
|
||||
|
||||
@@ -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,10 +15,12 @@ import {
|
||||
CLAUDE_OPUS_1M_TIERS,
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@/shared/api"
|
||||
@@ -87,6 +89,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 +140,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 +214,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 +240,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":
|
||||
@@ -273,12 +324,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 +365,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 +381,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 +403,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,11 @@ export class Task {
|
||||
const isOrgClinePassRestrictionError = clineError.isErrorType(
|
||||
ClineErrorType.OrgClinePassRestriction,
|
||||
);
|
||||
// ClinePass period limits reset in hours/days — auto-retrying is
|
||||
// pointless and only delays the actionable error UI.
|
||||
const isClinePassLimitError = clineError.isErrorType(
|
||||
ClineErrorType.ClinePassLimit,
|
||||
);
|
||||
|
||||
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
|
||||
const isClineProviderInsufficientCredits = (() => {
|
||||
@@ -2499,6 +2499,7 @@ export class Task {
|
||||
!quotaExceeded &&
|
||||
!isEntitlementError &&
|
||||
!isOrgClinePassRestrictionError &&
|
||||
!isClinePassLimitError &&
|
||||
this.taskState.autoRetryAttempts < 3;
|
||||
if (shouldRetry) {
|
||||
// Auto-retry enabled with max 3 attempts: automatically approve the retry
|
||||
@@ -2562,7 +2563,8 @@ export class Task {
|
||||
!isSpendLimitError &&
|
||||
!quotaExceeded &&
|
||||
!isEntitlementError &&
|
||||
!isOrgClinePassRestrictionError;
|
||||
!isOrgClinePassRestrictionError &&
|
||||
!isClinePassLimitError;
|
||||
if (showRetry) {
|
||||
await this.say(
|
||||
"error_retry",
|
||||
@@ -2825,6 +2827,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 +2859,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
|
||||
|
||||
@@ -53,10 +53,15 @@ import { ExtensionRegistryInfo } from "./registry"
|
||||
import { AuthService } from "./services/auth/AuthService"
|
||||
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.
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum ClineErrorType {
|
||||
QuotaExceeded = "quotaExceeded",
|
||||
Entitlement = "entitlement",
|
||||
OrgClinePassRestriction = "orgClinePassRestriction",
|
||||
ClinePassLimit = "clinePassLimit",
|
||||
}
|
||||
|
||||
interface ErrorDetails {
|
||||
@@ -57,6 +58,46 @@ const ORG_CLINE_PASS_RESTRICTION_MESSAGE =
|
||||
const ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE =
|
||||
"organization accounts cannot use clinepass subscriptions";
|
||||
|
||||
// The ClinePass period limit message is dynamic ("weekly"/"5-hour" period, "7d"/"12h"
|
||||
// reset), so it is matched by its fixed prefix/suffix with the ClinePass marker in
|
||||
// between. Plain indexOf scanning — no regex, so no backtracking on hostile input.
|
||||
const CLINE_PASS_LIMIT_PREFIX = "you have reached your";
|
||||
const CLINE_PASS_LIMIT_MARKER = "clinepass limit";
|
||||
const CLINE_PASS_LIMIT_SUFFIX = "please try again later.";
|
||||
|
||||
function findClinePassLimitMessageBounds(
|
||||
text: string,
|
||||
): { start: number; end: number } | undefined {
|
||||
const normalized = text.toLowerCase();
|
||||
const start = normalized.indexOf(CLINE_PASS_LIMIT_PREFIX);
|
||||
if (start === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const suffixStart = normalized.indexOf(CLINE_PASS_LIMIT_SUFFIX, start);
|
||||
if (suffixStart === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const end = suffixStart + CLINE_PASS_LIMIT_SUFFIX.length;
|
||||
if (!normalized.slice(start, end).includes(CLINE_PASS_LIMIT_MARKER)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function isClinePassLimitMessage(text: string): boolean {
|
||||
return findClinePassLimitMessageBounds(text) !== undefined;
|
||||
}
|
||||
|
||||
export function extractClinePassLimitMessage(
|
||||
text: string,
|
||||
): string | undefined {
|
||||
const bounds = findClinePassLimitMessageBounds(text);
|
||||
return bounds ? text.slice(bounds.start, bounds.end) : undefined;
|
||||
}
|
||||
|
||||
export class ClineError extends Error {
|
||||
readonly title = "ClineError";
|
||||
readonly _error: ErrorDetails;
|
||||
@@ -202,6 +243,18 @@ export class ClineError extends Error {
|
||||
return ClineErrorType.Entitlement;
|
||||
}
|
||||
|
||||
// ClinePass period limits (weekly/5-hour) are user-actionable (switch to
|
||||
// usage-based billing) and must not fall through to the generic 403 auth
|
||||
// handling below or the 429 rate-limit patterns.
|
||||
const detailMessage =
|
||||
typeof details?.message === "string" ? details.message : undefined;
|
||||
if (
|
||||
isClinePassLimitMessage(detailMessage ?? "") ||
|
||||
isClinePassLimitMessage(message ?? "")
|
||||
) {
|
||||
return ClineErrorType.ClinePassLimit;
|
||||
}
|
||||
|
||||
// Check auth errors
|
||||
const isAuthStatus = status !== undefined && status > 400 && status < 429;
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it } from "mocha";
|
||||
import "should";
|
||||
import { ClineError, ClineErrorType } from "../ClineError";
|
||||
import {
|
||||
ClineError,
|
||||
ClineErrorType,
|
||||
extractClinePassLimitMessage,
|
||||
isClinePassLimitMessage,
|
||||
} from "../ClineError";
|
||||
|
||||
describe("ClineError", () => {
|
||||
describe("getErrorType", () => {
|
||||
@@ -59,7 +64,7 @@ describe("ClineError", () => {
|
||||
"Error 403: the user is not subscribed to required model plan",
|
||||
},
|
||||
},
|
||||
"cline-pass/glm-5.1",
|
||||
"cline-pass/glm-5.2",
|
||||
"cline-pass",
|
||||
);
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
|
||||
@@ -89,5 +94,74 @@ describe("ClineError", () => {
|
||||
const result = ClineError.getErrorType(err);
|
||||
(result !== ClineErrorType.OrgClinePassRestriction).should.be.true();
|
||||
});
|
||||
|
||||
it("should classify ClinePass period limit messages as ClinePassLimit", () => {
|
||||
const err = new ClineError(
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
|
||||
);
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
|
||||
});
|
||||
|
||||
it("should classify nested ClinePass period limit messages as ClinePassLimit", () => {
|
||||
// ClineError maps `error.error` into `details`, matching the real provider error shape.
|
||||
const err = new ClineError({
|
||||
message: "403 Error 403",
|
||||
error: {
|
||||
message:
|
||||
"You have reached your monthly ClinePass limit. The limit resets in 12h, please try again later.",
|
||||
},
|
||||
});
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
|
||||
});
|
||||
|
||||
it("should prefer ClinePassLimit over Auth for a 403 with the limit message", () => {
|
||||
// status 403 falls inside the generic auth-status range; the limit message must win.
|
||||
const err = new ClineError({
|
||||
message:
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClinePassLimitMessage", () => {
|
||||
it("matches limit messages with variable period and reset", () => {
|
||||
isClinePassLimitMessage(
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
|
||||
).should.be.true();
|
||||
isClinePassLimitMessage(
|
||||
"You have reached your 5-hour ClinePass limit. The limit resets in 5h, please try again later.",
|
||||
).should.be.true();
|
||||
});
|
||||
|
||||
it("does not match unrelated or partial messages", () => {
|
||||
isClinePassLimitMessage(
|
||||
"the user is not subscribed to required model plan",
|
||||
).should.be.false();
|
||||
isClinePassLimitMessage(
|
||||
`You have reached your\t-\tClinepass limit.The limit resets in\t${"\t".repeat(10_000)}`,
|
||||
).should.be.false();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractClinePassLimitMessage", () => {
|
||||
it("extracts the limit message out of a wrapped error string", () => {
|
||||
const message =
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
|
||||
|
||||
extractClinePassLimitMessage(`429 Error: ${message}`)!.should.equal(
|
||||
message,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when there is no limit message", () => {
|
||||
(
|
||||
extractClinePassLimitMessage("some other error") === undefined
|
||||
).should.be.true();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
@@ -1400,6 +1435,33 @@ export class TelemetryService {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as assert from "assert"
|
||||
import { 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("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,43 @@
|
||||
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
|
||||
|
||||
/** Return rollout metadata only for bundles built by the combined rollout workflow. */
|
||||
export function getRolloutTelemetryMetadata(): Partial<RolloutTelemetryMetadata> {
|
||||
const variant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
|
||||
if (variant !== "legacy" && variant !== "next") {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { 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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
+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,
|
||||
|
||||
@@ -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,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"],
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1106,6 +1106,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
switch (selectedProvider) {
|
||||
case "cline":
|
||||
return `${selectedProvider}:${selectedModelId}`
|
||||
case "cline-pass":
|
||||
// Free models selected on ClinePass go through Cline usage billing,
|
||||
// so label them the same way as the cline provider
|
||||
return selectedModelId.startsWith("cline-pass/")
|
||||
? `${selectedProvider}:${selectedModelId.replace(/^cline-pass\//, "")}`
|
||||
: `cline:${selectedModelId}`
|
||||
case "openai":
|
||||
return `openai-compat:${selectedModelId}`
|
||||
case "vscode-lm":
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
|
||||
import { useState } from "react";
|
||||
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers";
|
||||
|
||||
interface ClinePassLimitErrorProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const ClinePassLimitError = ({ message }: ClinePassLimitErrorProps) => {
|
||||
const { handleFieldsChange } = useApiConfigurationHandlers();
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
const [didSwitch, setDidSwitch] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const handleSwitchToUsageBasedBilling = async () => {
|
||||
setIsSwitching(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await handleFieldsChange({
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "cline",
|
||||
});
|
||||
setDidSwitch(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to switch to Cline usage-based billing:", error);
|
||||
setError(
|
||||
"Failed to switch provider. Select Cline Usage-Billing in API Configuration settings.",
|
||||
);
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)"
|
||||
data-testid="cline-pass-limit-error"
|
||||
>
|
||||
<div className="text-error mb-2">ClinePass limit reached</div>
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
|
||||
{message}
|
||||
</div>
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
|
||||
Would you like to switch to Usage-Based billing and retry with the
|
||||
Cline provider?
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
className="w-full mt-3"
|
||||
disabled={isSwitching || didSwitch}
|
||||
onClick={handleSwitchToUsageBasedBilling}
|
||||
>
|
||||
{isSwitching
|
||||
? "Switching..."
|
||||
: didSwitch
|
||||
? "Switched to Usage-Based billing"
|
||||
: "Switch to Usage-Based billing"}
|
||||
</VSCodeButton>
|
||||
{didSwitch && (
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
|
||||
Retry the request after switching.
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-error text-xs mt-2">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClinePassLimitError;
|
||||
@@ -3,7 +3,9 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
|
||||
import { useClineAuth } from "@/context/ClineAuthContext"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { AccountServiceClient, TaskServiceClient } from "@/services/grpc-client"
|
||||
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
|
||||
|
||||
interface CreditLimitErrorProps {
|
||||
currentBalance: number
|
||||
@@ -26,7 +28,11 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
|
||||
totalSpent,
|
||||
}) => {
|
||||
const { activeOrganization } = useClineAuth()
|
||||
const { mode, navigateToSettings } = useExtensionState()
|
||||
const { handleModeFieldChange } = useApiConfigurationHandlers()
|
||||
const [fullBuyCreditsUrl, setFullBuyCreditsUrl] = useState<string>("")
|
||||
const [isSwitchingToClinePass, setIsSwitchingToClinePass] = useState(false)
|
||||
const [didSwitchToClinePass, setDidSwitchToClinePass] = useState(false)
|
||||
|
||||
const dashboardUrl = useMemo(() => {
|
||||
return buyCreditsUrl ?? (activeOrganization?.organizationId ? DEFAULT_BUY_CREDITS_URL.ORG : DEFAULT_BUY_CREDITS_URL.USER)
|
||||
@@ -48,6 +54,19 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
|
||||
fetchCallbackUrl()
|
||||
}, [dashboardUrl])
|
||||
|
||||
const handleSwitchToClinePass = async () => {
|
||||
setIsSwitchingToClinePass(true)
|
||||
try {
|
||||
await handleModeFieldChange({ plan: "planModeApiProvider", act: "actModeApiProvider" }, "cline-pass", mode)
|
||||
setDidSwitchToClinePass(true)
|
||||
navigateToSettings("api-config")
|
||||
} catch (error) {
|
||||
console.error("Failed to switch to ClinePass:", error)
|
||||
} finally {
|
||||
setIsSwitchingToClinePass(false)
|
||||
}
|
||||
}
|
||||
|
||||
// We have to divide because the balance is stored in microcredits
|
||||
return (
|
||||
<div className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)">
|
||||
@@ -66,6 +85,24 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs mb-2">
|
||||
Trying to use ClinePass instead of credits?
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
className="w-full"
|
||||
disabled={isSwitchingToClinePass || didSwitchToClinePass}
|
||||
onClick={handleSwitchToClinePass}>
|
||||
<span className="codicon codicon-arrow-swap mr-1.5" />
|
||||
{isSwitchingToClinePass
|
||||
? "Switching..."
|
||||
: didSwitchToClinePass
|
||||
? "Switched to ClinePass"
|
||||
: "Switch to ClinePass"}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<VSCodeButtonLink className="w-full mb-2" href={fullBuyCreditsUrl}>
|
||||
<span className="codicon codicon-credit-card mr-[6px] text-[14px]" />
|
||||
Buy Credits
|
||||
|
||||
@@ -223,7 +223,7 @@ export const ClinePassEntitlementError: Story = {
|
||||
message: "403 Error 403: the user is not subscribed to required model plan",
|
||||
status: 403,
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
modelId: "cline-pass/glm-5.1",
|
||||
modelId: "cline-pass/glm-5.2",
|
||||
providerId: "cline-pass",
|
||||
details: {
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage";
|
||||
import { ApiProvider } from "@shared/proto/cline/models";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import ErrorRow from "./ErrorRow";
|
||||
|
||||
const mockSetUserOrganization = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateApiConfigurationProto = vi.hoisted(() => vi.fn());
|
||||
const mockApiConfiguration = vi.hoisted(() => ({
|
||||
planModeApiProvider: "cline-pass",
|
||||
actModeApiProvider: "cline-pass",
|
||||
planModeClinePassModelId: "cline-pass/test-plan-model",
|
||||
actModeClinePassModelId: "cline-pass/test-act-model",
|
||||
}));
|
||||
|
||||
// Mock the auth context
|
||||
vi.mock("@/context/ClineAuthContext", () => ({
|
||||
@@ -30,10 +38,19 @@ vi.mock("@/components/chat/EntitlementError", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: () => ({
|
||||
apiConfiguration: mockApiConfiguration,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/grpc-client", () => ({
|
||||
AccountServiceClient: {
|
||||
setUserOrganization: mockSetUserOrganization,
|
||||
},
|
||||
ModelsServiceClient: {
|
||||
updateApiConfigurationProto: mockUpdateApiConfigurationProto,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ClineError
|
||||
@@ -47,7 +64,9 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
|
||||
Auth: "auth",
|
||||
Entitlement: "entitlement",
|
||||
OrgClinePassRestriction: "orgClinePassRestriction",
|
||||
ClinePassLimit: "clinePassLimit",
|
||||
},
|
||||
extractClinePassLimitMessage: vi.fn((text: string) => text),
|
||||
}));
|
||||
|
||||
describe("ErrorRow", () => {
|
||||
@@ -61,6 +80,7 @@ describe("ErrorRow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSetUserOrganization.mockResolvedValue({});
|
||||
mockUpdateApiConfigurationProto.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("renders basic error message", () => {
|
||||
@@ -266,6 +286,83 @@ describe("ErrorRow", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ClinePass limit error and switches to Cline usage-based billing", async () => {
|
||||
const limitMessage =
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
|
||||
const mockClineError = {
|
||||
message: limitMessage,
|
||||
isErrorType: vi.fn((type) => type === "clinePassLimit"),
|
||||
providerId: "cline-pass",
|
||||
_error: {
|
||||
message: limitMessage,
|
||||
},
|
||||
};
|
||||
|
||||
const { ClineError } = await import(
|
||||
"../../../../src/services/error/ClineError"
|
||||
);
|
||||
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
|
||||
|
||||
render(
|
||||
<ErrorRow
|
||||
apiRequestFailedMessage={limitMessage}
|
||||
errorType="error"
|
||||
message={mockMessage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("cline-pass-limit-error")).toBeInTheDocument();
|
||||
expect(screen.getByText(limitMessage)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Switch to Usage-Based billing"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateApiConfigurationProto).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
// The proto conversion maps provider id strings to ApiProvider enum values.
|
||||
const request = mockUpdateApiConfigurationProto.mock.calls[0][0];
|
||||
expect(request.apiConfiguration.planModeApiProvider).toBe(
|
||||
ApiProvider.CLINE,
|
||||
);
|
||||
expect(request.apiConfiguration.actModeApiProvider).toBe(
|
||||
ApiProvider.CLINE,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Switched to Usage-Based billing"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer the usage-based billing switch when already on the cline provider", async () => {
|
||||
const limitMessage =
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
|
||||
const mockClineError = {
|
||||
message: limitMessage,
|
||||
isErrorType: vi.fn((type) => type === "clinePassLimit"),
|
||||
providerId: "cline",
|
||||
_error: {
|
||||
message: limitMessage,
|
||||
},
|
||||
};
|
||||
|
||||
const { ClineError } = await import(
|
||||
"../../../../src/services/error/ClineError"
|
||||
);
|
||||
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
|
||||
|
||||
render(
|
||||
<ErrorRow
|
||||
apiRequestFailedMessage={limitMessage}
|
||||
errorType="error"
|
||||
message={mockMessage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("cline-pass-limit-error"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(limitMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders friendly logged-out message and sign in button when user is not signed in", async () => {
|
||||
const mockClineError = {
|
||||
message: "Authentication failed",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage";
|
||||
import { isClineProvider } from "@shared/utils/cline";
|
||||
import { memo } from "react";
|
||||
import ClinePassLimitError from "@/components/chat/ClinePassLimitError";
|
||||
import CreditLimitError from "@/components/chat/CreditLimitError";
|
||||
import EntitlementError from "@/components/chat/EntitlementError";
|
||||
import OrgClinePassRestrictionError from "@/components/chat/OrgClinePassRestrictionError";
|
||||
@@ -10,6 +11,7 @@ import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext";
|
||||
import {
|
||||
ClineError,
|
||||
ClineErrorType,
|
||||
extractClinePassLimitMessage,
|
||||
} from "../../../../src/services/error/ClineError";
|
||||
|
||||
const _errorColor = "var(--vscode-errorForeground)";
|
||||
@@ -90,6 +92,19 @@ const ErrorRow = memo(
|
||||
return <OrgClinePassRestrictionError />;
|
||||
}
|
||||
|
||||
// Gated on the ClinePass provider: users already on usage-based
|
||||
// billing shouldn't be offered a switch to what they're on.
|
||||
if (
|
||||
clineError?.isErrorType(ClineErrorType.ClinePassLimit) &&
|
||||
providerId === "cline-pass"
|
||||
) {
|
||||
const detailMessage =
|
||||
clineError?._error?.details?.message || errorMessage;
|
||||
const limitMessage =
|
||||
extractClinePassLimitMessage(detailMessage) ?? detailMessage;
|
||||
return <ClinePassLimitError message={limitMessage} />;
|
||||
}
|
||||
|
||||
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
|
||||
return (
|
||||
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
|
||||
|
||||
+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])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getClineUIOnboardingGroups, getRecommendedModelsData } from "../data-models"
|
||||
import { CLINEPASS_GROUP, getClineUIOnboardingGroups, getRecommendedModelsData } from "../data-models"
|
||||
|
||||
function model(id: string, group: string): OnboardingModel {
|
||||
return {
|
||||
@@ -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"),
|
||||
model("cline-pass/glm-5.2", CLINEPASS_GROUP),
|
||||
model("free-model", "free"),
|
||||
model("anthropic/claude", "frontier"),
|
||||
model("z-ai/glm", "open source"),
|
||||
@@ -30,12 +30,18 @@ describe("getClineUIOnboardingGroups", () => {
|
||||
)
|
||||
|
||||
expect(result.clinePass).toHaveLength(1)
|
||||
expect(result.clinePass[0].group).toBe("clinepass")
|
||||
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.1"])
|
||||
expect(result.clinePass[0].group).toBe(CLINEPASS_GROUP)
|
||||
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"])
|
||||
})
|
||||
|
||||
it("does not bucket cline-pass ids without a ClinePass group label", () => {
|
||||
const result = getClineUIOnboardingGroups(groupOf([model("cline-pass/glm-5.2", "frontier")]))
|
||||
|
||||
expect(result.clinePass).toEqual([])
|
||||
})
|
||||
|
||||
it("returns an empty clinePass group when no ClinePass models are present", () => {
|
||||
const result = getClineUIOnboardingGroups(groupOf([model("free-model", "free")]))
|
||||
expect(result.clinePass).toEqual([])
|
||||
@@ -48,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,
|
||||
)
|
||||
@@ -61,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", () => {
|
||||
@@ -74,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,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ClineRecommendedModel, OpenRouterModelInfo } from "@shared/proto/cline/models"
|
||||
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
|
||||
|
||||
export const CLINEPASS_GROUP = "cline-pass"
|
||||
|
||||
export interface RecommendedModelsData {
|
||||
recommended: ClineRecommendedModel[]
|
||||
free: ClineRecommendedModel[]
|
||||
@@ -39,16 +41,20 @@ interface ModelGroup {
|
||||
models: OnboardingModel[]
|
||||
}
|
||||
|
||||
function isClinePassOnboardingModel(model: OnboardingModel): boolean {
|
||||
return model.group === CLINEPASS_GROUP
|
||||
}
|
||||
|
||||
export function getClineUIOnboardingGroups(groupedModels: OnboardingModelGroup): OnboardingModelsByGroup {
|
||||
const { models } = groupedModels
|
||||
|
||||
const clinePassModels = models.filter((m) => m.group === "clinepass")
|
||||
const clinePassModels = models.filter(isClinePassOnboardingModel)
|
||||
const freeModels = models.filter((m) => m.group === "free")
|
||||
const frontierModels = models.filter((m) => m.group === "frontier")
|
||||
const openSourceModels = models.filter((m) => m.group === "open source")
|
||||
|
||||
return {
|
||||
clinePass: clinePassModels.length > 0 ? [{ group: "clinepass", models: clinePassModels }] : [],
|
||||
clinePass: clinePassModels.length > 0 ? [{ group: CLINEPASS_GROUP, models: clinePassModels }] : [],
|
||||
free: freeModels.length > 0 ? [{ group: "free", models: freeModels }] : [],
|
||||
power: [
|
||||
...(frontierModels.length > 0 ? [{ group: "frontier", models: frontierModels }] : []),
|
||||
|
||||
@@ -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,11 +4,9 @@ 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 { getRecommendedModelsData, type RecommendedModelsData } from "./data-models"
|
||||
import { CLINEPASS_GROUP, getRecommendedModelsData, type RecommendedModelsData } from "./data-models"
|
||||
|
||||
export type OnboardingModelsStatus = "loading" | "success" | "empty"
|
||||
|
||||
@@ -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])
|
||||
@@ -107,7 +104,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
|
||||
const clinePassCatalog = Object.fromEntries(
|
||||
data.clinePass.map((rec) => [rec.id, resolveClinePassModelInfo(rec.id, openRouterModelsByName)]),
|
||||
)
|
||||
const clinePassModels = data.clinePass.map((rec) => toOnboardingModel(rec, "clinepass", "", clinePassCatalog))
|
||||
const clinePassModels = data.clinePass.map((rec) => toOnboardingModel(rec, CLINEPASS_GROUP, "", clinePassCatalog))
|
||||
|
||||
return { status: "success", models: { models: [...clinePassModels, ...freeModels, ...frontierModels] } }
|
||||
}, [fetchState, modelCatalog, openRouterModelsByName])
|
||||
|
||||
@@ -9,9 +9,7 @@ import styled from "styled-components"
|
||||
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
|
||||
import { ModelsServiceClient } from "@/services/grpc-client"
|
||||
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
|
||||
import { AIhubmixProvider } from "./providers/AihubmixProvider"
|
||||
@@ -21,7 +19,6 @@ import { BasetenProvider } from "./providers/BasetenProvider"
|
||||
import { BedrockProvider } from "./providers/BedrockProvider"
|
||||
import { CerebrasProvider } from "./providers/CerebrasProvider"
|
||||
import { ClaudeCodeProvider } from "./providers/ClaudeCodeProvider"
|
||||
import { ClinePassProvider } from "./providers/ClinePassProvider"
|
||||
import { ClineProvider } from "./providers/ClineProvider"
|
||||
import { DeepSeekProvider } from "./providers/DeepSeekProvider"
|
||||
import { DifyProvider } from "./providers/DifyProvider"
|
||||
@@ -102,9 +99,8 @@ const ApiOptions = ({
|
||||
}: ApiOptionsProps) => {
|
||||
// Use full context state for immediate save payload
|
||||
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
|
||||
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
|
||||
|
||||
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode, { isClinePassEnabled })
|
||||
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode, { isClinePassEnabled: true })
|
||||
|
||||
const { handleModeFieldChange } = useApiConfigurationHandlers()
|
||||
|
||||
@@ -145,9 +141,6 @@ const ApiOptions = ({
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
let providers = PROVIDERS.list
|
||||
if (!isClinePassEnabled) {
|
||||
providers = providers.filter((option) => option.value !== "cline-pass")
|
||||
}
|
||||
// Filter by platform
|
||||
if (PLATFORM_CONFIG.type !== PlatformType.VSCODE) {
|
||||
// Don't include VS Code LM API for non-VSCode platforms
|
||||
@@ -157,15 +150,24 @@ const ApiOptions = ({
|
||||
// Filter by remote config if remoteConfiguredProviders is set
|
||||
const remoteProviders: string[] = remoteConfigSettings?.remoteConfiguredProviders || []
|
||||
if (remoteProviders.length > 0) {
|
||||
providers = providers.filter((option) => remoteProviders.includes(option.value))
|
||||
const effectiveRemoteProviders =
|
||||
remoteProviders.includes("cline-pass") && !remoteProviders.includes("cline")
|
||||
? [...remoteProviders, "cline"]
|
||||
: remoteProviders
|
||||
providers = providers.filter((option) => effectiveRemoteProviders.includes(option.value))
|
||||
}
|
||||
|
||||
return providers
|
||||
}, [isClinePassEnabled, remoteConfigSettings])
|
||||
}, [remoteConfigSettings])
|
||||
|
||||
const getProviderDisplayLabel = useCallback((option: (typeof PROVIDERS.list)[number]) => {
|
||||
return option.value === "cline" ? "Cline Usage-Billing" : option.label
|
||||
}, [])
|
||||
|
||||
const currentProviderLabel = useMemo(() => {
|
||||
return providerOptions.find((option) => option.value === selectedProvider)?.label || selectedProvider
|
||||
}, [providerOptions, selectedProvider])
|
||||
const selectedOption = providerOptions.find((option) => option.value === selectedProvider)
|
||||
return selectedOption ? getProviderDisplayLabel(selectedOption) : selectedProvider
|
||||
}, [getProviderDisplayLabel, providerOptions, selectedProvider])
|
||||
|
||||
// Sync search term with current provider when not searching
|
||||
useEffect(() => {
|
||||
@@ -177,13 +179,19 @@ const ApiOptions = ({
|
||||
const searchableItems = useMemo(() => {
|
||||
return providerOptions.map((option) => ({
|
||||
value: option.value,
|
||||
html: option.label,
|
||||
html: getProviderDisplayLabel(option),
|
||||
searchText:
|
||||
option.value === "cline"
|
||||
? "Cline Usage Billing usage based pay as you go"
|
||||
: option.value === "cline-pass"
|
||||
? "ClinePass subscription included models"
|
||||
: option.label,
|
||||
}))
|
||||
}, [providerOptions])
|
||||
}, [getProviderDisplayLabel, providerOptions])
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(searchableItems, {
|
||||
keys: ["html"],
|
||||
keys: ["html", "searchText"],
|
||||
threshold: 0.3,
|
||||
shouldSort: true,
|
||||
isCaseSensitive: false,
|
||||
@@ -366,20 +374,17 @@ const ApiOptions = ({
|
||||
<HicapProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
|
||||
)}
|
||||
|
||||
{apiConfiguration && selectedProvider === "cline" && (
|
||||
{apiConfiguration && (selectedProvider === "cline" || selectedProvider === "cline-pass") && (
|
||||
<ClineProvider
|
||||
currentMode={currentMode}
|
||||
initialModelTab={initialModelTab}
|
||||
isClinePassEnabled={isClinePassEnabled}
|
||||
isClinePassEnabled={true}
|
||||
isPopup={isPopup}
|
||||
selectedProvider={selectedProvider}
|
||||
showModelOptions={showModelOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiConfiguration && isClinePassEnabled && selectedProvider === "cline-pass" && (
|
||||
<ClinePassProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
|
||||
)}
|
||||
|
||||
{apiConfiguration && selectedProvider === "asksage" && (
|
||||
<AskSageProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ClineAccountInfoCard = () => {
|
||||
<div className="max-w-[600px]">
|
||||
{user ? (
|
||||
<VSCodeButton appearance="secondary" onClick={handleShowAccount}>
|
||||
View Billing & Usage
|
||||
View Billing History
|
||||
</VSCodeButton>
|
||||
) : (
|
||||
<div>
|
||||
@@ -37,7 +37,7 @@ export const ClineAccountInfoCard = () => {
|
||||
Sign Up with Cline
|
||||
{isLoading && (
|
||||
<span className="ml-1 animate-spin">
|
||||
<span className="codicon codicon-refresh"></span>
|
||||
<span className="codicon codicon-refresh" />
|
||||
</span>
|
||||
)}
|
||||
</VSCodeButton>
|
||||
|
||||
@@ -47,6 +47,13 @@ const StarIcon = ({ isFavorite, onClick }: { isFavorite: boolean; onClick: (e: R
|
||||
)
|
||||
}
|
||||
|
||||
export interface FeaturedModelTab {
|
||||
label: string
|
||||
models: FeaturedModelCardEntry[]
|
||||
// Optional explanatory copy shown between the tab bar and the model cards
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ClineModelPickerProps {
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
@@ -58,12 +65,17 @@ export interface ClineModelPickerProps {
|
||||
models?: Record<string, ModelInfo>
|
||||
isClinePassEnabled?: boolean
|
||||
showFeaturedModels?: boolean
|
||||
// Custom featured tabs (e.g. ClinePass "Subscribed"/"Free") shown instead of the
|
||||
// built-in Recommended/Free tabs
|
||||
featuredTabs?: FeaturedModelTab[]
|
||||
}
|
||||
|
||||
interface FeaturedModelCardEntry {
|
||||
export interface FeaturedModelCardEntry {
|
||||
id: string
|
||||
description: string
|
||||
label: string
|
||||
// Shown on the card instead of the id (e.g. ClinePass ids without their prefix)
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_RETRY_DELAY_MS = 5000
|
||||
@@ -72,7 +84,7 @@ function normalizeModelId(modelId: string): string {
|
||||
return modelId.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function toFeaturedModelCardEntry(
|
||||
export function toFeaturedModelCardEntry(
|
||||
model: Pick<ClineRecommendedModel, "id" | "description" | "tags">,
|
||||
fallbackLabel: string,
|
||||
): FeaturedModelCardEntry | null {
|
||||
@@ -109,6 +121,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
|
||||
models,
|
||||
isClinePassEnabled = true,
|
||||
showFeaturedModels = true,
|
||||
featuredTabs,
|
||||
}) => {
|
||||
const { handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
|
||||
const { apiConfiguration, favoritedModelIds, clineModels, openRouterModels, refreshClineModels } = useExtensionState()
|
||||
@@ -150,6 +163,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
|
||||
[freeClineModelIds],
|
||||
)
|
||||
const [activeTab, setActiveTab] = useState<"recommended" | "free">(initialTab ?? "recommended")
|
||||
const [activeFeaturedTabIndex, setActiveFeaturedTabIndex] = useState(0)
|
||||
const recommendedModels = useMemo(
|
||||
() => (clineRecommendedModels.length > 0 ? clineRecommendedModels : RECOMMENDED_MODELS_FALLBACK),
|
||||
[clineRecommendedModels],
|
||||
@@ -230,11 +244,26 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
|
||||
const currentModelId = resolveModelId(configuredModelId)
|
||||
setActiveTab(freeClineModelIdSet.has(normalizeModelId(currentModelId)) ? "free" : "recommended")
|
||||
}, [configuredModelId, freeClineModelIdSet, initialTab, resolveModelId])
|
||||
|
||||
// Keep the active custom tab on the tab containing the configured model
|
||||
useEffect(() => {
|
||||
if (!featuredTabs) {
|
||||
return
|
||||
}
|
||||
const currentModelId = resolveModelId(configuredModelId)
|
||||
const tabIndex = featuredTabs.findIndex((tab) => tab.models.some((model) => model.id === currentModelId))
|
||||
if (tabIndex >= 0) {
|
||||
setActiveFeaturedTabIndex(tabIndex)
|
||||
}
|
||||
}, [configuredModelId, featuredTabs, resolveModelId])
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleModelChange = (newModelId: string) => {
|
||||
const handleModelChange = (rawModelId: string) => {
|
||||
// When a fixed models map is provided (ClinePass), only ids in the map may be
|
||||
// stored — otherwise the host would send arbitrary typed text to the API.
|
||||
const newModelId = models && !(rawModelId in models) ? resolveModelId(rawModelId) : rawModelId
|
||||
setSearchTerm(newModelId)
|
||||
|
||||
handleModeFieldsChange(
|
||||
@@ -414,6 +443,8 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
|
||||
Object.entries(resolvedModels ?? {})?.some(([id, m]) => id === selectedModelId && m.thinkingConfig) ||
|
||||
selectedModelIdLower.includes("claude-haiku-4.5") ||
|
||||
selectedModelIdLower.includes("claude-4.5-haiku") ||
|
||||
selectedModelIdLower.includes("claude-sonnet-5") ||
|
||||
selectedModelIdLower.includes("claude-5-sonnet") ||
|
||||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
|
||||
selectedModelIdLower.includes("claude-sonnet-4-6") ||
|
||||
selectedModelIdLower.includes("claude-4.6-sonnet") ||
|
||||
@@ -442,7 +473,52 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
|
||||
{showFeaturedModels && (
|
||||
{showFeaturedModels && featuredTabs && (
|
||||
<>
|
||||
{/* Custom Tabs (e.g. ClinePass Subscribed/Free) */}
|
||||
<TabsContainer style={{ marginTop: 4 }}>
|
||||
{featuredTabs.map((tab, index) => (
|
||||
<Tab
|
||||
active={activeFeaturedTabIndex === index}
|
||||
key={tab.label}
|
||||
onClick={() => setActiveFeaturedTabIndex(index)}>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabsContainer>
|
||||
|
||||
{/* Tab Description */}
|
||||
{featuredTabs[activeFeaturedTabIndex]?.description && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
margin: "4px 0 6px 0",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
{featuredTabs[activeFeaturedTabIndex].description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Model Cards */}
|
||||
<div style={{ marginBottom: "6px" }}>
|
||||
{featuredTabs[activeFeaturedTabIndex]?.models.map((model) => (
|
||||
<FeaturedModelCard
|
||||
description={model.description}
|
||||
isSelected={selectedModelId === model.id}
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
modelId={model.displayName ?? model.id}
|
||||
onClick={() => {
|
||||
handleModelChange(model.id)
|
||||
setIsDropdownVisible(false)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showFeaturedModels && !featuredTabs && (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<TabsContainer style={{ marginTop: 4 }}>
|
||||
@@ -570,6 +646,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 +746,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>
|
||||
|
||||
@@ -179,6 +179,8 @@ const VercelModelPicker: React.FC<VercelModelPickerProps> = ({ isPopup, currentM
|
||||
return (
|
||||
selectedModelIdLower.includes("claude-haiku-4.5") ||
|
||||
selectedModelIdLower.includes("claude-4.5-haiku") ||
|
||||
selectedModelIdLower.includes("claude-sonnet-5") ||
|
||||
selectedModelIdLower.includes("claude-5-sonnet") ||
|
||||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
|
||||
selectedModelIdLower.includes("claude-sonnet-4-6") ||
|
||||
selectedModelIdLower.includes("claude-4.6-sonnet") ||
|
||||
|
||||
@@ -189,8 +189,9 @@ export const ModelInfoView = ({
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(false)
|
||||
|
||||
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
|
||||
const hidePricing = selectedModelId.trim().toLowerCase().startsWith("cline-pass/")
|
||||
const hasThinkingConfig = hasThinkingBudget(modelInfo)
|
||||
const hasTiers = !!modelInfo.tiers && modelInfo.tiers.length > 0
|
||||
const hasTiers = !hidePricing && !!modelInfo.tiers && modelInfo.tiers.length > 0
|
||||
|
||||
// Capability checks
|
||||
const hasImages = supportsImages(modelInfo)
|
||||
@@ -198,7 +199,8 @@ export const ModelInfoView = ({
|
||||
const hasCaching = !isGemini && supportsPromptCache(modelInfo)
|
||||
|
||||
// Check if we have cache pricing to show in Advanced section
|
||||
const hasCachePricing = modelInfo.supportsPromptCache && (modelInfo.cacheWritesPrice || modelInfo.cacheReadsPrice)
|
||||
const hasCachePricing =
|
||||
!hidePricing && modelInfo.supportsPromptCache && (modelInfo.cacheWritesPrice || modelInfo.cacheReadsPrice)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
@@ -215,13 +217,13 @@ export const ModelInfoView = ({
|
||||
<InfoValue>{formatCompactContext(modelInfo.contextWindow)}</InfoValue>
|
||||
</InfoItem>
|
||||
)}
|
||||
{modelInfo.inputPrice !== undefined && (
|
||||
{!hidePricing && modelInfo.inputPrice !== undefined && (
|
||||
<InfoItem>
|
||||
<InfoLabel>Input: </InfoLabel>
|
||||
<InfoValue>{formatCompactPrice(modelInfo.inputPrice)}</InfoValue>
|
||||
</InfoItem>
|
||||
)}
|
||||
{modelInfo.outputPrice !== undefined && (
|
||||
{!hidePricing && modelInfo.outputPrice !== undefined && (
|
||||
<InfoItem>
|
||||
<InfoLabel>Output: </InfoLabel>
|
||||
<InfoValue>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandler
|
||||
|
||||
// Anthropic models that support thinking/reasoning mode
|
||||
export const SUPPORTED_ANTHROPIC_THINKING_MODELS = [
|
||||
"claude-sonnet-5",
|
||||
`claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`,
|
||||
"claude-sonnet-4-6",
|
||||
`claude-sonnet-4-6${CLAUDE_SONNET_1M_SUFFIX}`,
|
||||
"claude-3-7-sonnet-20250219",
|
||||
@@ -90,6 +92,14 @@ export const AnthropicProvider = ({ showModelOptions, isPopup, currentMode }: An
|
||||
selectedModelId={selectedModelId}
|
||||
/>
|
||||
|
||||
{/* Context window switcher for Claude Sonnet 5 */}
|
||||
<ContextWindowSwitcher
|
||||
base1mModelId={`claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`}
|
||||
base200kModelId="claude-sonnet-5"
|
||||
onModelChange={handleModelChange}
|
||||
selectedModelId={selectedModelId}
|
||||
/>
|
||||
|
||||
{/* Context window switcher for Claude Opus 4.6 */}
|
||||
<ContextWindowSwitcher
|
||||
base1mModelId={`claude-opus-4-6${CLAUDE_SONNET_1M_SUFFIX}`}
|
||||
|
||||
@@ -24,6 +24,8 @@ import { getModeSpecificFields, normalizeApiConfiguration } from "../utils/provi
|
||||
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
|
||||
|
||||
export const SUPPORTED_BEDROCK_THINKING_MODELS = [
|
||||
"anthropic.claude-sonnet-5",
|
||||
`anthropic.claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`,
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
`anthropic.claude-sonnet-4-6${CLAUDE_SONNET_1M_SUFFIX}`,
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
|
||||
@@ -13,7 +13,9 @@ const SUPPORTED_CLAUDE_CODE_THINKING_MODELS = [
|
||||
...SUPPORTED_ANTHROPIC_THINKING_MODELS,
|
||||
"sonnet",
|
||||
"sonnet[1m]",
|
||||
"claude-sonnet-5[1m]",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-opus-5[1m]",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-7[1m]",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
|
||||
@@ -1,46 +1,89 @@
|
||||
import {
|
||||
buildModelInfoNameMap,
|
||||
clinePassDefaultModelId,
|
||||
clinePassModelInfoSaneDefaults,
|
||||
clinePassModels,
|
||||
type ModelInfo,
|
||||
resolveClinePassModelInfo,
|
||||
} from "@shared/api"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import type { ClineRecommendedModel } from "@shared/proto/cline/models"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { ModelsServiceClient } from "@/services/grpc-client"
|
||||
import { ClineAccountInfoCard } from "../ClineAccountInfoCard"
|
||||
import ClineModelPicker from "../ClineModelPicker"
|
||||
import { ClineProvider } from "./ClineProvider"
|
||||
import ClineModelPicker, {
|
||||
type FeaturedModelCardEntry,
|
||||
type FeaturedModelTab,
|
||||
toFeaturedModelCardEntry,
|
||||
} from "../ClineModelPicker"
|
||||
import { getModeSpecificFields } from "../utils/providerUtils"
|
||||
|
||||
export const ClinePassProvider: typeof ClineProvider = (props) => {
|
||||
const { openRouterModels } = useExtensionState()
|
||||
interface ClinePassProviderProps {
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
showAccountCard?: boolean
|
||||
}
|
||||
|
||||
const CLINE_PASS_MODEL_FIELD_PAIRS = {
|
||||
modelId: { plan: "planModeClinePassModelId", act: "actModeClinePassModelId" },
|
||||
modelInfo: { plan: "planModeClinePassModelInfo", act: "actModeClinePassModelInfo" },
|
||||
} as const
|
||||
|
||||
const FREE_TAB_DESCRIPTION =
|
||||
"Try these models with limited free usage, included at no cost and separate from your ClinePass quota."
|
||||
|
||||
function zeroPriced(info: ModelInfo): ModelInfo {
|
||||
return {
|
||||
...info,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
cacheWritesPrice: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export const ClinePassProvider = ({
|
||||
showModelOptions,
|
||||
isPopup,
|
||||
currentMode,
|
||||
showAccountCard = true,
|
||||
}: ClinePassProviderProps) => {
|
||||
const { apiConfiguration, openRouterModels, clineModels, refreshClineModels } = useExtensionState()
|
||||
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
|
||||
const [clinePassRawModels, setClinePassRawModels] = useState<ClineRecommendedModel[]>([])
|
||||
const [clinePassRecommendedModels, setClinePassRecommendedModels] = useState<Record<string, ModelInfo> | undefined>(undefined)
|
||||
const [clineFreeModels, setClineFreeModels] = useState<ClineRecommendedModel[]>([])
|
||||
|
||||
const refreshClinePassModels = useCallback(async () => {
|
||||
try {
|
||||
const response = await ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
|
||||
const clinePassResponseModels = (response.clinePass ?? []).filter((model) => model.id)
|
||||
const models = Object.fromEntries(
|
||||
(response.clinePass ?? [])
|
||||
.filter((model) => model.id)
|
||||
.map((model) => {
|
||||
// ClinePass model IDs omit the upstream lab, so look up capabilities using
|
||||
// the model slug (for example, glm-5.1 instead of cline-pass/glm-5.1).
|
||||
// If the model is not in OpenRouter yet, use conservative generic defaults
|
||||
// instead of copying GLM-5.1-specific context/max-token values.
|
||||
const fallback = resolveClinePassModelInfo(model.id, openRouterModelsByName)
|
||||
return [
|
||||
model.id,
|
||||
{
|
||||
...fallback,
|
||||
name: model.name || fallback.name || model.id,
|
||||
description: model.description || fallback.description,
|
||||
},
|
||||
]
|
||||
}),
|
||||
clinePassResponseModels.map((model) => {
|
||||
// ClinePass model IDs omit the upstream lab, so look up capabilities using
|
||||
// the model slug (for example, glm-5.2 instead of cline-pass/glm-5.2).
|
||||
// If the model is not in OpenRouter yet, use conservative generic defaults
|
||||
// instead of copying GLM-5.2-specific context/max-token values.
|
||||
const fallback = resolveClinePassModelInfo(model.id, openRouterModelsByName)
|
||||
return [
|
||||
model.id,
|
||||
{
|
||||
...fallback,
|
||||
name: model.name || fallback.name || model.id,
|
||||
// The info panel shows the full catalog description; the endpoint's
|
||||
// short blurb is only for the featured cards
|
||||
description: fallback.description || model.description,
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
setClinePassRawModels(clinePassResponseModels)
|
||||
setClinePassRecommendedModels(Object.keys(models).length > 0 ? models : undefined)
|
||||
setClineFreeModels((response.free ?? []).filter((model) => model.id))
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh ClinePass models:", error)
|
||||
}
|
||||
@@ -50,7 +93,54 @@ export const ClinePassProvider: typeof ClineProvider = (props) => {
|
||||
void refreshClinePassModels()
|
||||
}, [refreshClinePassModels])
|
||||
|
||||
const clinePassModelOptions = clinePassRecommendedModels ?? clinePassModels
|
||||
// The picker skips its own catalog refresh when a models map is provided, but the
|
||||
// free-model entries below resolve their capabilities from the cline catalog.
|
||||
useEffect(() => {
|
||||
refreshClineModels()
|
||||
}, [refreshClineModels])
|
||||
|
||||
const freeRecommendedModels = useMemo(
|
||||
() => (clineFreeModels.length > 0 ? clineFreeModels : CLINE_RECOMMENDED_MODELS_FALLBACK.free),
|
||||
[clineFreeModels],
|
||||
)
|
||||
|
||||
// Free models are OpenRouter-style ids billed at $0, so resolve their info by full id
|
||||
// from the dynamic catalogs and store them zero-priced.
|
||||
const freeModelEntries = useMemo(() => {
|
||||
const modelCatalog: Record<string, ModelInfo> = { ...openRouterModels, ...clineModels }
|
||||
return Object.fromEntries(
|
||||
freeRecommendedModels
|
||||
.filter((model) => model.id)
|
||||
.map((model) => {
|
||||
const base = modelCatalog[model.id] ?? clinePassModelInfoSaneDefaults
|
||||
return [
|
||||
model.id,
|
||||
zeroPriced({
|
||||
...base,
|
||||
name: model.name || base.name || model.id,
|
||||
// The info panel shows the full catalog description; the endpoint's
|
||||
// short blurb is only for the featured cards
|
||||
description: base.description || model.description,
|
||||
}),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}, [freeRecommendedModels, openRouterModels, clineModels])
|
||||
|
||||
const { clinePassModelId: configuredClinePassModelId, clinePassModelInfo: configuredClinePassModelInfo } =
|
||||
getModeSpecificFields(apiConfiguration, currentMode)
|
||||
|
||||
const clinePassModelOptions = useMemo(() => {
|
||||
// ClinePass entries first so the default-model fallback below stays a pass model.
|
||||
const merged: Record<string, ModelInfo> = { ...(clinePassRecommendedModels ?? clinePassModels), ...freeModelEntries }
|
||||
// Keep a previously selected model visible even if it later drops out of the
|
||||
// endpoint's buckets, so the picker doesn't display a model the host won't send.
|
||||
if (configuredClinePassModelId && !(configuredClinePassModelId in merged) && configuredClinePassModelInfo) {
|
||||
merged[configuredClinePassModelId] = configuredClinePassModelInfo
|
||||
}
|
||||
return merged
|
||||
}, [clinePassRecommendedModels, freeModelEntries, configuredClinePassModelId, configuredClinePassModelInfo])
|
||||
|
||||
const clinePassDefaultModel = useMemo(() => {
|
||||
if (!clinePassModelOptions) {
|
||||
return undefined
|
||||
@@ -61,20 +151,59 @@ export const ClinePassProvider: typeof ClineProvider = (props) => {
|
||||
: (Object.keys(clinePassModelOptions)[0] ?? clinePassDefaultModelId)
|
||||
}, [clinePassModelOptions])
|
||||
|
||||
// Subscription models show their endpoint description but no label chip — the
|
||||
// whole list is included with the plan, so a per-card tag adds nothing
|
||||
const subscribedModelCards = useMemo<FeaturedModelCardEntry[]>(() => {
|
||||
const models =
|
||||
clinePassRawModels.length > 0
|
||||
? clinePassRawModels.map((model) => ({ id: model.id, description: model.description }))
|
||||
: Object.entries(clinePassRecommendedModels ?? clinePassModels).map(([id, info]) => ({
|
||||
id,
|
||||
description: info.description,
|
||||
}))
|
||||
return models.map((model) => ({
|
||||
id: model.id,
|
||||
displayName: model.id.replace(/^cline-pass\//, ""),
|
||||
label: "",
|
||||
description: model.description || "",
|
||||
}))
|
||||
}, [clinePassRawModels, clinePassRecommendedModels])
|
||||
|
||||
const freeModelCards = useMemo(
|
||||
() =>
|
||||
freeRecommendedModels
|
||||
.map((model) => toFeaturedModelCardEntry(model, "FREE"))
|
||||
.filter((model): model is FeaturedModelCardEntry => model !== null),
|
||||
[freeRecommendedModels],
|
||||
)
|
||||
|
||||
const featuredTabs = useMemo<FeaturedModelTab[]>(() => {
|
||||
const tabs: FeaturedModelTab[] = [{ label: "Subscribed", models: subscribedModelCards }]
|
||||
if (freeModelCards.length > 0) {
|
||||
tabs.push({ label: "Free", models: freeModelCards, description: FREE_TAB_DESCRIPTION })
|
||||
}
|
||||
return tabs
|
||||
}, [subscribedModelCards, freeModelCards])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 14, marginTop: 4 }}>
|
||||
<ClineAccountInfoCard />
|
||||
</div>
|
||||
{showAccountCard && (
|
||||
<div style={{ marginBottom: 14, marginTop: 4 }}>
|
||||
<ClineAccountInfoCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ClineModelPicker
|
||||
{...props}
|
||||
defaultModelId={clinePassDefaultModel}
|
||||
modelIdFieldPair={{ plan: "planModeClinePassModelId", act: "actModeClinePassModelId" }}
|
||||
modelInfoFieldPair={{ plan: "planModeClinePassModelInfo", act: "actModeClinePassModelInfo" }}
|
||||
models={clinePassModelOptions}
|
||||
showFeaturedModels={false}
|
||||
/>
|
||||
{showModelOptions && (
|
||||
<ClineModelPicker
|
||||
currentMode={currentMode}
|
||||
defaultModelId={clinePassDefaultModel}
|
||||
featuredTabs={featuredTabs}
|
||||
isPopup={isPopup}
|
||||
modelIdFieldPair={CLINE_PASS_MODEL_FIELD_PAIRS.modelId}
|
||||
modelInfoFieldPair={CLINE_PASS_MODEL_FIELD_PAIRS.modelInfo}
|
||||
models={clinePassModelOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import styled from "styled-components"
|
||||
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
|
||||
import { useClineAuth } from "@/context/ClineAuthContext"
|
||||
import { buildClinePassSubscriptionUrl } from "@/utils/clinePassSubscription"
|
||||
import { ClineAccountInfoCard } from "../ClineAccountInfoCard"
|
||||
import ClineModelPicker from "../ClineModelPicker"
|
||||
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
|
||||
import { ClinePassProvider } from "./ClinePassProvider"
|
||||
|
||||
/**
|
||||
* Props for the ClineProvider component
|
||||
*/
|
||||
interface ClineProviderProps {
|
||||
export interface ClineProviderProps {
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
initialModelTab?: "recommended" | "free"
|
||||
isClinePassEnabled?: boolean
|
||||
selectedProvider?: ApiProvider
|
||||
}
|
||||
|
||||
type ClineBillingRoute = "cline" | "cline-pass"
|
||||
|
||||
/**
|
||||
* The Cline provider configuration component
|
||||
*/
|
||||
@@ -22,23 +32,128 @@ export const ClineProvider = ({
|
||||
currentMode,
|
||||
initialModelTab,
|
||||
isClinePassEnabled,
|
||||
selectedProvider,
|
||||
}: ClineProviderProps) => {
|
||||
const { handleModeFieldChange } = useApiConfigurationHandlers()
|
||||
const { clineUser } = useClineAuth()
|
||||
const activeRoute: ClineBillingRoute = selectedProvider === "cline-pass" && isClinePassEnabled ? "cline-pass" : "cline"
|
||||
const clinePassSubscribeUrl = clineUser ? buildClinePassSubscriptionUrl(clineUser.appBaseUrl) : undefined
|
||||
|
||||
const handleRouteChange = async (route: ClineBillingRoute) => {
|
||||
if (route === activeRoute) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleModeFieldChange({ plan: "planModeApiProvider", act: "actModeApiProvider" }, route, currentMode)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Cline Account Info Card */}
|
||||
<div style={{ marginBottom: 14, marginTop: 4 }}>
|
||||
<ClineAccountInfoCard />
|
||||
</div>
|
||||
{isClinePassEnabled && (
|
||||
<RouteContainer>
|
||||
<RouteStatus>
|
||||
{activeRoute === "cline-pass" ? (
|
||||
<>
|
||||
ClinePass is a low cost subscription plan including usage of the best open weights models.{" "}
|
||||
<RouteLink
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
handleRouteChange("cline").catch((error) => console.error("Failed to switch to Cline:", error))
|
||||
}}>
|
||||
Switch to Cline Usage-Billing for other models.
|
||||
</RouteLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Usage-Billing models bill to your Cline account balance.{" "}
|
||||
<RouteLink
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
handleRouteChange("cline-pass").catch((error) =>
|
||||
console.error("Failed to switch to ClinePass:", error),
|
||||
)
|
||||
}}>
|
||||
Switch to ClinePass provider to access subscription.
|
||||
</RouteLink>
|
||||
</>
|
||||
)}
|
||||
</RouteStatus>
|
||||
<RouteActions>
|
||||
{activeRoute === "cline" && <ClineAccountInfoCard />}
|
||||
{activeRoute === "cline-pass" && (
|
||||
clinePassSubscribeUrl ? (
|
||||
<VSCodeButtonLink appearance="secondary" href={clinePassSubscribeUrl}>
|
||||
Manage ClinePass or See Usage
|
||||
</VSCodeButtonLink>
|
||||
) : (
|
||||
<ClineAccountInfoCard />
|
||||
)
|
||||
)}
|
||||
</RouteActions>
|
||||
</RouteContainer>
|
||||
)}
|
||||
{!isClinePassEnabled && (
|
||||
<StandaloneRouteActions>
|
||||
<ClineAccountInfoCard />
|
||||
</StandaloneRouteActions>
|
||||
)}
|
||||
|
||||
{showModelOptions && (
|
||||
<ClineModelPicker
|
||||
currentMode={currentMode}
|
||||
initialTab={initialModelTab}
|
||||
isClinePassEnabled={isClinePassEnabled}
|
||||
isPopup={isPopup}
|
||||
showProviderRouting={true}
|
||||
/>
|
||||
activeRoute === "cline-pass" ? (
|
||||
<ClinePassProvider
|
||||
currentMode={currentMode}
|
||||
isPopup={isPopup}
|
||||
showAccountCard={false}
|
||||
showModelOptions={showModelOptions}
|
||||
/>
|
||||
) : (
|
||||
<ClineModelPicker
|
||||
currentMode={currentMode}
|
||||
initialTab={initialModelTab}
|
||||
isClinePassEnabled={isClinePassEnabled}
|
||||
isPopup={isPopup}
|
||||
showProviderRouting={true}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RouteContainer = styled.div`
|
||||
margin-bottom: 10px;
|
||||
`
|
||||
|
||||
const RouteStatus = styled.div`
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
`
|
||||
|
||||
const RouteLink = styled.a`
|
||||
color: var(--vscode-textLink-foreground);
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
cursor: pointer !important;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-textLink-activeForeground, var(--vscode-textLink-foreground));
|
||||
cursor: pointer !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`
|
||||
|
||||
const RouteActions = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
const StandaloneRouteActions = styled(RouteActions)`
|
||||
margin: 4px 0 14px;
|
||||
`
|
||||
|
||||
@@ -4,9 +4,16 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { ApiKeyField } from "../common/ApiKeyField"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { ModelSelector } from "../common/ModelSelector"
|
||||
import ReasoningEffortSelector from "../ReasoningEffortSelector"
|
||||
import { normalizeApiConfiguration } from "../utils/providerUtils"
|
||||
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
|
||||
|
||||
const DEEPSEEK_REASONING_EFFORT_MODELS = new Set([
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-reasoner",
|
||||
])
|
||||
|
||||
/**
|
||||
* Props for the DeepSeekProvider component
|
||||
*/
|
||||
@@ -25,6 +32,7 @@ export const DeepSeekProvider = ({ showModelOptions, isPopup, currentMode }: Dee
|
||||
|
||||
// Get the normalized configuration
|
||||
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, currentMode)
|
||||
const showReasoningEffort = DEEPSEEK_REASONING_EFFORT_MODELS.has(selectedModelId)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -50,6 +58,8 @@ export const DeepSeekProvider = ({ showModelOptions, isPopup, currentMode }: Dee
|
||||
selectedModelId={selectedModelId}
|
||||
/>
|
||||
|
||||
{showReasoningEffort && <ReasoningEffortSelector currentMode={currentMode} />}
|
||||
|
||||
<ModelInfoView isPopup={isPopup} modelInfo={selectedModelInfo} selectedModelId={selectedModelId} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { vertexGlobalModels, vertexModels } from "@shared/api"
|
||||
import { type ModelInfo, vertexCustomModelInfoSaneDefaults, vertexGlobalModels, vertexModels } from "@shared/api"
|
||||
import VertexData from "@shared/providers/vertex.json"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
|
||||
import { VSCodeDropdown, VSCodeLink, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeCheckbox, VSCodeDropdown, VSCodeLink, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { DROPDOWN_Z_INDEX, DropdownContainer } from "../ApiOptions"
|
||||
import { DebouncedTextField } from "../common/DebouncedTextField"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { ModelSelector } from "../common/ModelSelector"
|
||||
import { LockIcon, RemotelyConfiguredInputWrapper } from "../common/RemotelyConfiguredInputWrapper"
|
||||
import ReasoningEffortSelector from "../ReasoningEffortSelector"
|
||||
import ThinkingBudgetSlider from "../ThinkingBudgetSlider"
|
||||
@@ -25,14 +24,26 @@ interface VertexProviderProps {
|
||||
|
||||
// Vertex models that support thinking
|
||||
const SUPPORTED_THINKING_MODELS = [
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-5:1m",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4-6:1m",
|
||||
"claude-fable-5",
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4-5@20251001",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5@20250929",
|
||||
"claude-3-7-sonnet@20250219",
|
||||
"claude-sonnet-4@20250514",
|
||||
"claude-opus-4@20250514",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-1@20250805",
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-5",
|
||||
"claude-opus-5:1m",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash-lite-preview-06-17",
|
||||
@@ -45,7 +56,7 @@ const REGIONS = VertexData.regions
|
||||
*/
|
||||
export const VertexProvider = ({ showModelOptions, isPopup, currentMode }: VertexProviderProps) => {
|
||||
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
|
||||
const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers()
|
||||
const { handleFieldChange, handleModeFieldChange, handleModeFieldsChange } = useApiConfigurationHandlers()
|
||||
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
|
||||
|
||||
// Get the normalized configuration
|
||||
@@ -57,6 +68,17 @@ export const VertexProvider = ({ showModelOptions, isPopup, currentMode }: Verte
|
||||
// Determine which models to use based on region
|
||||
const modelsToUse = apiConfiguration?.vertexRegion === "global" ? vertexGlobalModels : vertexModels
|
||||
|
||||
const isCustomModelSelected = !!modeFields.vertexCustomModelSelected
|
||||
const customModelInfo = modeFields.vertexCustomModelInfo ?? vertexCustomModelInfoSaneDefaults
|
||||
|
||||
const handleCustomModelInfoChange = (updates: Partial<ModelInfo>) => {
|
||||
handleModeFieldChange(
|
||||
{ plan: "planModeVertexCustomModelInfo", act: "actModeVertexCustomModelInfo" },
|
||||
{ ...customModelInfo, ...updates },
|
||||
currentMode,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -125,19 +147,126 @@ export const VertexProvider = ({ showModelOptions, isPopup, currentMode }: Verte
|
||||
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<ModelSelector
|
||||
label="Model"
|
||||
models={modelsToUse}
|
||||
onChange={(e: any) =>
|
||||
handleModeFieldChange(
|
||||
{ plan: "planModeApiModelId", act: "actModeApiModelId" },
|
||||
e.target.value,
|
||||
currentMode,
|
||||
)
|
||||
}
|
||||
selectedModelId={selectedModelId}
|
||||
zIndex={DROPDOWN_Z_INDEX - 2}
|
||||
/>
|
||||
<DropdownContainer className="dropdown-container" zIndex={DROPDOWN_Z_INDEX - 2}>
|
||||
<label htmlFor="vertex-model-dropdown">
|
||||
<span className="font-medium">Model</span>
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
className="w-full"
|
||||
id="vertex-model-dropdown"
|
||||
onChange={(e: any) => {
|
||||
const isCustom = e.target.value === "custom"
|
||||
|
||||
handleModeFieldsChange(
|
||||
{
|
||||
apiModelId: { plan: "planModeApiModelId", act: "actModeApiModelId" },
|
||||
vertexCustomModelSelected: {
|
||||
plan: "planModeVertexCustomModelSelected",
|
||||
act: "actModeVertexCustomModelSelected",
|
||||
},
|
||||
vertexCustomModelInfo: {
|
||||
plan: "planModeVertexCustomModelInfo",
|
||||
act: "actModeVertexCustomModelInfo",
|
||||
},
|
||||
},
|
||||
{
|
||||
apiModelId: isCustom ? "" : e.target.value,
|
||||
vertexCustomModelSelected: isCustom,
|
||||
vertexCustomModelInfo: isCustom ? { ...vertexCustomModelInfoSaneDefaults } : undefined,
|
||||
},
|
||||
currentMode,
|
||||
)
|
||||
}}
|
||||
value={isCustomModelSelected ? "custom" : selectedModelId}>
|
||||
<VSCodeOption value="">Select a model...</VSCodeOption>
|
||||
{Object.keys(modelsToUse).map((modelId) => (
|
||||
<VSCodeOption
|
||||
className="whitespace-normal wrap-break-word max-w-full"
|
||||
key={modelId}
|
||||
value={modelId}>
|
||||
{modelId}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
<VSCodeOption value="custom">Custom</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</DropdownContainer>
|
||||
|
||||
{isCustomModelSelected && (
|
||||
<div>
|
||||
<p className="mt-1 text-sm text-description">
|
||||
Select "Custom" to use a Vertex AI model that isn't in the list. Enter the model ID and adjust the
|
||||
model's capabilities below if needed.
|
||||
</p>
|
||||
<DebouncedTextField
|
||||
className="w-full mt-0.5"
|
||||
id="vertex-custom-model-input"
|
||||
initialValue={modeFields.apiModelId || ""}
|
||||
onChange={(value) =>
|
||||
handleModeFieldChange(
|
||||
{ plan: "planModeApiModelId", act: "actModeApiModelId" },
|
||||
value,
|
||||
currentMode,
|
||||
)
|
||||
}
|
||||
placeholder="Enter custom model ID...">
|
||||
<span className="font-medium">Model ID</span>
|
||||
</DebouncedTextField>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
|
||||
<DebouncedTextField
|
||||
initialValue={
|
||||
customModelInfo.contextWindow?.toString() ??
|
||||
vertexCustomModelInfoSaneDefaults.contextWindow?.toString() ??
|
||||
""
|
||||
}
|
||||
onChange={(value) => {
|
||||
// Only save valid values so clearing the field doesn't
|
||||
// snap it back to the previously saved value mid-edit.
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
handleCustomModelInfoChange({ contextWindow: parsed })
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}>
|
||||
<span className="font-medium">Context Window Size</span>
|
||||
</DebouncedTextField>
|
||||
|
||||
<DebouncedTextField
|
||||
initialValue={
|
||||
customModelInfo.maxTokens?.toString() ??
|
||||
vertexCustomModelInfoSaneDefaults.maxTokens?.toString() ??
|
||||
""
|
||||
}
|
||||
onChange={(value) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
handleCustomModelInfoChange({ maxTokens: parsed })
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}>
|
||||
<span className="font-medium">Max Output Tokens</span>
|
||||
</DebouncedTextField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<VSCodeCheckbox
|
||||
checked={!!customModelInfo.supportsImages}
|
||||
onChange={(e: any) =>
|
||||
handleCustomModelInfoChange({ supportsImages: e.target.checked === true })
|
||||
}>
|
||||
Supports Images
|
||||
</VSCodeCheckbox>
|
||||
|
||||
<VSCodeCheckbox
|
||||
checked={!!customModelInfo.supportsReasoning}
|
||||
onChange={(e: any) =>
|
||||
handleCustomModelInfoChange({ supportsReasoning: e.target.checked === true })
|
||||
}>
|
||||
Supports Reasoning
|
||||
</VSCodeCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdaptiveThinkingModel ? (
|
||||
<ReasoningEffortSelector
|
||||
@@ -147,7 +276,8 @@ export const VertexProvider = ({ showModelOptions, isPopup, currentMode }: Verte
|
||||
description="Use None to disable adaptive thinking. Higher effort increases response detail and token usage."
|
||||
label="Adaptive Thinking"
|
||||
/>
|
||||
) : SUPPORTED_THINKING_MODELS.includes(selectedModelId) ? (
|
||||
) : SUPPORTED_THINKING_MODELS.includes(selectedModelId) ||
|
||||
(isCustomModelSelected && customModelInfo.supportsReasoning) ? (
|
||||
<ThinkingBudgetSlider currentMode={currentMode} maxBudget={selectedModelInfo.thinkingConfig?.maxBudget} />
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import { clinePassDefaultModelId, clinePassModels } from "@shared/api"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { normalizeApiConfiguration, syncModeConfigurations } from "../providerUtils"
|
||||
|
||||
describe("providerUtils", () => {
|
||||
const freeModelInfo: ModelInfo = {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 256_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
cacheWritesPrice: 0,
|
||||
description: "A free model",
|
||||
}
|
||||
|
||||
describe("normalizeApiConfiguration cline-pass", () => {
|
||||
it("passes a free (non cline-pass prefixed) model id through with its stored info", () => {
|
||||
const apiConfiguration: ApiConfiguration = {
|
||||
actModeApiProvider: "cline-pass",
|
||||
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
|
||||
actModeClinePassModelInfo: freeModelInfo,
|
||||
}
|
||||
|
||||
const normalized = normalizeApiConfiguration(apiConfiguration, "act", { isClinePassEnabled: true })
|
||||
expect(normalized.selectedProvider).toBe("cline-pass")
|
||||
expect(normalized.selectedModelId).toBe("kwaipilot/kat-coder-pro")
|
||||
expect(normalized.selectedModelInfo).toEqual(freeModelInfo)
|
||||
})
|
||||
|
||||
it("passes a :free suffixed model id through with its stored info", () => {
|
||||
const apiConfiguration: ApiConfiguration = {
|
||||
actModeApiProvider: "cline-pass",
|
||||
actModeClinePassModelId: "arcee-ai/trinity-large-preview:free",
|
||||
actModeClinePassModelInfo: freeModelInfo,
|
||||
}
|
||||
|
||||
const normalized = normalizeApiConfiguration(apiConfiguration, "act", { isClinePassEnabled: true })
|
||||
expect(normalized.selectedModelId).toBe("arcee-ai/trinity-large-preview:free")
|
||||
expect(normalized.selectedModelInfo).toEqual(freeModelInfo)
|
||||
})
|
||||
|
||||
it("keeps cline-pass prefixed ids resolved against the static model table", () => {
|
||||
const apiConfiguration: ApiConfiguration = {
|
||||
actModeApiProvider: "cline-pass",
|
||||
actModeClinePassModelId: "cline-pass/glm-5.2",
|
||||
}
|
||||
|
||||
const normalized = normalizeApiConfiguration(apiConfiguration, "act", { isClinePassEnabled: true })
|
||||
expect(normalized.selectedModelId).toBe("cline-pass/glm-5.2")
|
||||
expect(normalized.selectedModelInfo).toEqual(clinePassModels["cline-pass/glm-5.2"])
|
||||
})
|
||||
|
||||
it("falls back to the default pass model when no model id is configured", () => {
|
||||
const apiConfiguration: ApiConfiguration = {
|
||||
actModeApiProvider: "cline-pass",
|
||||
}
|
||||
|
||||
const normalized = normalizeApiConfiguration(apiConfiguration, "act", { isClinePassEnabled: true })
|
||||
expect(normalized.selectedModelId).toBe(clinePassDefaultModelId)
|
||||
expect(normalized.selectedModelInfo).toEqual(clinePassModels[clinePassDefaultModelId])
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncModeConfigurations cline-pass", () => {
|
||||
it("copies a free model id and info across plan and act modes", async () => {
|
||||
const apiConfiguration: ApiConfiguration = {
|
||||
planModeApiProvider: "cline-pass",
|
||||
actModeApiProvider: "cline-pass",
|
||||
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
|
||||
actModeClinePassModelInfo: freeModelInfo,
|
||||
}
|
||||
|
||||
let updates: Partial<ApiConfiguration> | undefined
|
||||
await syncModeConfigurations(apiConfiguration, "act", async (fields) => {
|
||||
updates = fields
|
||||
})
|
||||
|
||||
expect(updates?.planModeClinePassModelId).toBe("kwaipilot/kat-coder-pro")
|
||||
expect(updates?.planModeClinePassModelInfo).toEqual(freeModelInfo)
|
||||
expect(updates?.actModeClinePassModelId).toBe("kwaipilot/kat-coder-pro")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
basetenModels,
|
||||
bedrockDefaultModelId,
|
||||
bedrockModels,
|
||||
buildModelInfoNameMap,
|
||||
cerebrasDefaultModelId,
|
||||
cerebrasModels,
|
||||
claudeCodeDefaultModelId,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
fireworksModels,
|
||||
geminiDefaultModelId,
|
||||
geminiModels,
|
||||
getVertexCustomModelInfo,
|
||||
groqDefaultModelId,
|
||||
groqModels,
|
||||
hicapModelInfoSaneDefaults,
|
||||
@@ -261,8 +263,24 @@ export function normalizeApiConfiguration(
|
||||
}
|
||||
return getProviderData(bedrockModels, bedrockDefaultModelId);
|
||||
}
|
||||
case "vertex":
|
||||
case "vertex": {
|
||||
const vertexCustomModelSelected =
|
||||
currentMode === "plan"
|
||||
? apiConfiguration?.planModeVertexCustomModelSelected
|
||||
: apiConfiguration?.actModeVertexCustomModelSelected;
|
||||
if (vertexCustomModelSelected) {
|
||||
const vertexCustomModelInfo =
|
||||
currentMode === "plan"
|
||||
? apiConfiguration?.planModeVertexCustomModelInfo
|
||||
: apiConfiguration?.actModeVertexCustomModelInfo;
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: modelId || "",
|
||||
selectedModelInfo: getVertexCustomModelInfo(vertexCustomModelInfo),
|
||||
};
|
||||
}
|
||||
return getProviderData(vertexModels, vertexDefaultModelId);
|
||||
}
|
||||
case "gemini":
|
||||
return getProviderData(geminiModels, geminiDefaultModelId);
|
||||
case "openai-native":
|
||||
@@ -354,23 +372,25 @@ export function normalizeApiConfiguration(
|
||||
currentMode === "plan"
|
||||
? apiConfiguration?.planModeClinePassModelId
|
||||
: apiConfiguration?.actModeClinePassModelId;
|
||||
const clinePassModelId = configuredClinePassModelId?.startsWith(
|
||||
"cline-pass/",
|
||||
)
|
||||
? configuredClinePassModelId
|
||||
: clinePassDefaultModelId;
|
||||
// ClinePass users may also select Cline free models (OpenRouter-style ids
|
||||
// without the cline-pass/ prefix), so pass any configured id through.
|
||||
const clinePassModelId =
|
||||
configuredClinePassModelId || clinePassDefaultModelId;
|
||||
const clinePassModelInfo =
|
||||
(currentMode === "plan"
|
||||
currentMode === "plan"
|
||||
? apiConfiguration?.planModeClinePassModelInfo
|
||||
: apiConfiguration?.actModeClinePassModelInfo) ||
|
||||
resolveClinePassModelInfo(
|
||||
clinePassModelId,
|
||||
options.clinePassModelInfoByName,
|
||||
);
|
||||
: apiConfiguration?.actModeClinePassModelInfo;
|
||||
const clinePassModelInfoByName = clinePassModelInfo
|
||||
? buildModelInfoNameMap({ [clinePassModelId]: clinePassModelInfo })
|
||||
: options.clinePassModelInfoByName;
|
||||
const resolvedClinePassModelInfo = resolveClinePassModelInfo(
|
||||
clinePassModelId,
|
||||
clinePassModelInfoByName,
|
||||
);
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: clinePassModelId,
|
||||
selectedModelInfo: clinePassModelInfo,
|
||||
selectedModelInfo: resolvedClinePassModelInfo,
|
||||
};
|
||||
}
|
||||
case "openai": {
|
||||
@@ -705,6 +725,10 @@ export function getModeSpecificFields(
|
||||
awsBedrockCustomSelected: undefined,
|
||||
awsBedrockCustomModelBaseId: undefined,
|
||||
|
||||
// Vertex custom model fields
|
||||
vertexCustomModelSelected: undefined,
|
||||
vertexCustomModelInfo: undefined,
|
||||
|
||||
// Huawei Cloud Maas Model Info
|
||||
huaweiCloudMaasModelInfo: undefined,
|
||||
|
||||
@@ -876,6 +900,16 @@ export function getModeSpecificFields(
|
||||
? apiConfiguration.planModeAwsBedrockCustomModelBaseId
|
||||
: apiConfiguration.actModeAwsBedrockCustomModelBaseId,
|
||||
|
||||
// Vertex custom model fields
|
||||
vertexCustomModelSelected:
|
||||
mode === "plan"
|
||||
? apiConfiguration.planModeVertexCustomModelSelected
|
||||
: apiConfiguration.actModeVertexCustomModelSelected,
|
||||
vertexCustomModelInfo:
|
||||
mode === "plan"
|
||||
? apiConfiguration.planModeVertexCustomModelInfo
|
||||
: apiConfiguration.actModeVertexCustomModelInfo,
|
||||
|
||||
// Huawei Cloud Maas Model Info
|
||||
huaweiCloudMaasModelInfo:
|
||||
mode === "plan"
|
||||
@@ -1033,6 +1067,19 @@ export async function syncModeConfigurations(
|
||||
updates.actModeAwsBedrockCustomModelBaseId =
|
||||
sourceFields.awsBedrockCustomModelBaseId;
|
||||
break;
|
||||
|
||||
case "vertex":
|
||||
updates.planModeApiModelId = sourceFields.apiModelId;
|
||||
updates.actModeApiModelId = sourceFields.apiModelId;
|
||||
updates.planModeVertexCustomModelSelected =
|
||||
sourceFields.vertexCustomModelSelected;
|
||||
updates.actModeVertexCustomModelSelected =
|
||||
sourceFields.vertexCustomModelSelected;
|
||||
updates.planModeVertexCustomModelInfo =
|
||||
sourceFields.vertexCustomModelInfo;
|
||||
updates.actModeVertexCustomModelInfo =
|
||||
sourceFields.vertexCustomModelInfo;
|
||||
break;
|
||||
case "huawei-cloud-maas":
|
||||
updates.planModeHuaweiCloudMaasModelId =
|
||||
sourceFields.huaweiCloudMaasModelId;
|
||||
@@ -1088,7 +1135,6 @@ export async function syncModeConfigurations(
|
||||
// Providers that use apiProvider + apiModelId fields
|
||||
case "anthropic":
|
||||
case "claude-code":
|
||||
case "vertex":
|
||||
case "gemini":
|
||||
case "openai-native":
|
||||
case "openai-codex":
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Webview copies of feature-flag strings. Must match the extension's FeatureFlag
|
||||
// enum, which can't be imported here (it pulls in Node-only deps).
|
||||
export const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
|
||||
@@ -0,0 +1,14 @@
|
||||
const DEFAULT_CLINE_APP_BASE_URL = "https://app.cline.bot"
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "dashboard/subscription"
|
||||
|
||||
export function buildClinePassSubscriptionUrl(appBaseUrl?: string): string {
|
||||
try {
|
||||
const baseUrl = appBaseUrl || DEFAULT_CLINE_APP_BASE_URL
|
||||
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`
|
||||
const url = new URL(CLINE_PASS_SUBSCRIPTION_PATH, base)
|
||||
url.searchParams.set("personal", "true")
|
||||
return url.toString()
|
||||
} catch {
|
||||
return `${DEFAULT_CLINE_APP_BASE_URL}/${CLINE_PASS_SUBSCRIPTION_PATH}?personal=true`
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiConfiguration, clinePassDefaultModelId, clinePassModels, ModelInfo, openRouterDefaultModelId } from "@shared/api"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { getModeSpecificFields } from "@/components/settings/utils/providerUtils"
|
||||
|
||||
@@ -222,7 +223,10 @@ export function validateModelId(
|
||||
}
|
||||
if (
|
||||
!Object.keys(clinePassModels).includes(clinePassResolvedModelId) &&
|
||||
!clinePassResolvedModelId.startsWith("cline-pass/")
|
||||
!clinePassResolvedModelId.startsWith("cline-pass/") &&
|
||||
// ClinePass users may also select Cline free models (OpenRouter-style ids)
|
||||
!(clineModels && Object.keys(clineModels).includes(clinePassResolvedModelId)) &&
|
||||
!CLINE_RECOMMENDED_MODELS_FALLBACK.free.some((model) => model.id === clinePassResolvedModelId)
|
||||
) {
|
||||
return "The model ID you provided is not available. Please choose a different model."
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/// <reference types="vitest/config" />
|
||||
|
||||
import { writeFileSync } from "node:fs"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react-swc"
|
||||
import { resolve } from "path"
|
||||
import { defineConfig, type Plugin, ViteDevServer } from "vite"
|
||||
import { writeFileSync } from "node:fs";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, loadEnv, type Plugin, type ViteDevServer } from "vite";
|
||||
|
||||
// Custom plugin to write the server port to a file
|
||||
const writePortToFile = (): Plugin => {
|
||||
@@ -12,135 +12,166 @@ const writePortToFile = (): Plugin => {
|
||||
name: "write-port-to-file",
|
||||
configureServer(server: ViteDevServer) {
|
||||
server.httpServer?.once("listening", () => {
|
||||
const address = server.httpServer?.address()
|
||||
const port = typeof address === "object" && address ? address.port : null
|
||||
const address = server.httpServer?.address();
|
||||
const port =
|
||||
typeof address === "object" && address ? address.port : null;
|
||||
|
||||
if (port) {
|
||||
const portFilePath = resolve(__dirname, ".vite-port")
|
||||
writeFileSync(portFilePath, port.toString())
|
||||
const portFilePath = resolve(__dirname, ".vite-port");
|
||||
writeFileSync(portFilePath, port.toString());
|
||||
} else {
|
||||
console.warn("[writePortToFile] Could not determine server port")
|
||||
console.warn("[writePortToFile] Could not determine server port");
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const isDevBuild = process.argv.includes("--dev-build")
|
||||
const isDevBuild = process.argv.includes("--dev-build");
|
||||
|
||||
// Valid platforms, these should the keys in platform-configs.json
|
||||
const VALID_PLATFORMS = ["vscode", "standalone"]
|
||||
const platform = process.env.PLATFORM || "vscode" // Default to vscode
|
||||
const VALID_PLATFORMS = ["vscode", "standalone"];
|
||||
|
||||
if (!VALID_PLATFORMS.includes(platform)) {
|
||||
throw new Error(`Invalid PLATFORM "${platform}". Must be one of: ${VALID_PLATFORMS.join(", ")}`)
|
||||
}
|
||||
console.log("Building webview for", platform)
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = {
|
||||
...loadEnv(mode, __dirname, ""),
|
||||
...process.env,
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
optimizeDeps: {
|
||||
force: true, // Forces re-optimization
|
||||
},
|
||||
plugins: [react(), tailwindcss(), writePortToFile()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/setupTests.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reportOnFailure: true,
|
||||
reporter: ["html", "lcov", "text"],
|
||||
reportsDirectory: "./coverage",
|
||||
exclude: [
|
||||
"**/*.{spec,test}.{js,jsx,ts,tsx,mjs,cjs}",
|
||||
const platform = env.PLATFORM || "vscode"; // Default to vscode
|
||||
|
||||
"**/*.d.ts",
|
||||
"**/vite-env.d.ts",
|
||||
"**/*.{config,setup}.{js,ts,mjs,cjs}",
|
||||
if (!VALID_PLATFORMS.includes(platform)) {
|
||||
throw new Error(
|
||||
`Invalid PLATFORM "${platform}". Must be one of: ${VALID_PLATFORMS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
console.log("Building webview for", platform);
|
||||
|
||||
"**/*.{css,scss,sass,less,styl}",
|
||||
"**/*.{svg,png,jpg,jpeg,gif,ico}",
|
||||
|
||||
"**/*.{json,yaml,yml}",
|
||||
|
||||
"**/__mocks__/**",
|
||||
"node_modules/**",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"dist/**",
|
||||
"public/**",
|
||||
|
||||
"src/services/grpc-client.ts",
|
||||
],
|
||||
return {
|
||||
base: "./",
|
||||
optimizeDeps: {
|
||||
force: true, // Forces re-optimization
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "build",
|
||||
reportCompressedSize: false,
|
||||
// Only minify in production build
|
||||
minify: !isDevBuild,
|
||||
// Enable inline source maps for dev build
|
||||
sourcemap: isDevBuild ? "inline" : false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: `assets/[name].js`,
|
||||
chunkFileNames: `assets/[name].js`,
|
||||
assetFileNames: `assets/[name].[ext]`,
|
||||
// Disable compact output for dev build
|
||||
compact: !isDevBuild,
|
||||
// Add generous formatting for dev build
|
||||
...(isDevBuild && {
|
||||
generatedCode: {
|
||||
constBindings: false,
|
||||
objectShorthand: false,
|
||||
arrowFunctions: false,
|
||||
},
|
||||
}),
|
||||
plugins: [react(), tailwindcss(), writePortToFile()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/setupTests.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reportOnFailure: true,
|
||||
reporter: ["html", "lcov", "text"],
|
||||
reportsDirectory: "./coverage",
|
||||
exclude: [
|
||||
"**/*.{spec,test}.{js,jsx,ts,tsx,mjs,cjs}",
|
||||
|
||||
"**/*.d.ts",
|
||||
"**/vite-env.d.ts",
|
||||
"**/*.{config,setup}.{js,ts,mjs,cjs}",
|
||||
|
||||
"**/*.{css,scss,sass,less,styl}",
|
||||
"**/*.{svg,png,jpg,jpeg,gif,ico}",
|
||||
|
||||
"**/*.{json,yaml,yml}",
|
||||
|
||||
"**/__mocks__/**",
|
||||
"node_modules/**",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"dist/**",
|
||||
"public/**",
|
||||
|
||||
"src/services/grpc-client.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 100000,
|
||||
},
|
||||
server: {
|
||||
port: 25463,
|
||||
hmr: {
|
||||
host: "localhost",
|
||||
protocol: "ws",
|
||||
build: {
|
||||
outDir: "build",
|
||||
reportCompressedSize: false,
|
||||
// Only minify in production build
|
||||
minify: !isDevBuild,
|
||||
// Enable inline source maps for dev build
|
||||
sourcemap: isDevBuild ? "inline" : false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: `assets/[name].js`,
|
||||
chunkFileNames: `assets/[name].js`,
|
||||
assetFileNames: `assets/[name].[ext]`,
|
||||
// Disable compact output for dev build
|
||||
compact: !isDevBuild,
|
||||
// Add generous formatting for dev build
|
||||
...(isDevBuild && {
|
||||
generatedCode: {
|
||||
constBindings: false,
|
||||
objectShorthand: false,
|
||||
arrowFunctions: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 100000,
|
||||
},
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: "*",
|
||||
allowedHeaders: "*",
|
||||
server: {
|
||||
port: 25463,
|
||||
hmr: {
|
||||
host: "localhost",
|
||||
protocol: "ws",
|
||||
},
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: "*",
|
||||
allowedHeaders: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__PLATFORM__: JSON.stringify(platform),
|
||||
__NODE_PLATFORM__: JSON.stringify(process.platform),
|
||||
"process.env.CLINE_ENVIRONMENT": JSON.stringify(process.env.CLINE_ENVIRONMENT ?? "production"),
|
||||
"process.env.IS_DEV": JSON.stringify(process.env.IS_DEV),
|
||||
"process.env.IS_TEST": JSON.stringify(process.env.IS_TEST),
|
||||
"process.env.CI": JSON.stringify(process.env.CI),
|
||||
// PostHog environment variables
|
||||
"process.env.TELEMETRY_SERVICE_API_KEY": JSON.stringify(process.env.TELEMETRY_SERVICE_API_KEY),
|
||||
"process.env.ERROR_SERVICE_API_KEY": JSON.stringify(process.env.ERROR_SERVICE_API_KEY),
|
||||
"process.env.ENABLE_ERROR_AUTOCAPTURE": JSON.stringify(process.env.ENABLE_ERROR_AUTOCAPTURE),
|
||||
// OpenTelemetry environment variables
|
||||
"process.env.OTEL_TELEMETRY_ENABLED": JSON.stringify(process.env.OTEL_TELEMETRY_ENABLED),
|
||||
"process.env.OTEL_METRICS_EXPORTER": JSON.stringify(process.env.OTEL_METRICS_EXPORTER),
|
||||
"process.env.OTEL_LOGS_EXPORTER": JSON.stringify(process.env.OTEL_LOGS_EXPORTER),
|
||||
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_PROTOCOL),
|
||||
"process.env.OTEL_EXPORTER_OTLP_ENDPOINT": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
|
||||
"process.env.OTEL_EXPORTER_OTLP_HEADERS": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_HEADERS),
|
||||
"process.env.OTEL_METRIC_EXPORT_INTERVAL": JSON.stringify(process.env.OTEL_METRIC_EXPORT_INTERVAL),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
"@components": resolve(__dirname, "./src/components"),
|
||||
"@context": resolve(__dirname, "./src/context"),
|
||||
"@shared": resolve(__dirname, "../src/shared"),
|
||||
"@utils": resolve(__dirname, "./src/utils"),
|
||||
define: {
|
||||
__PLATFORM__: JSON.stringify(platform),
|
||||
__NODE_PLATFORM__: JSON.stringify(process.platform),
|
||||
"process.env.CLINE_ENVIRONMENT": JSON.stringify(
|
||||
env.CLINE_ENVIRONMENT ?? "production",
|
||||
),
|
||||
"process.env.IS_DEV": JSON.stringify(env.IS_DEV),
|
||||
"process.env.IS_TEST": JSON.stringify(env.IS_TEST),
|
||||
"process.env.CI": JSON.stringify(env.CI),
|
||||
// PostHog environment variables
|
||||
"process.env.TELEMETRY_SERVICE_API_KEY": JSON.stringify(
|
||||
env.TELEMETRY_SERVICE_API_KEY,
|
||||
),
|
||||
"process.env.ERROR_SERVICE_API_KEY": JSON.stringify(
|
||||
env.ERROR_SERVICE_API_KEY,
|
||||
),
|
||||
"process.env.ENABLE_ERROR_AUTOCAPTURE": JSON.stringify(
|
||||
env.ENABLE_ERROR_AUTOCAPTURE,
|
||||
),
|
||||
// OpenTelemetry environment variables
|
||||
"process.env.OTEL_TELEMETRY_ENABLED": JSON.stringify(
|
||||
env.OTEL_TELEMETRY_ENABLED,
|
||||
),
|
||||
"process.env.OTEL_METRICS_EXPORTER": JSON.stringify(
|
||||
env.OTEL_METRICS_EXPORTER,
|
||||
),
|
||||
"process.env.OTEL_LOGS_EXPORTER": JSON.stringify(env.OTEL_LOGS_EXPORTER),
|
||||
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": JSON.stringify(
|
||||
env.OTEL_EXPORTER_OTLP_PROTOCOL,
|
||||
),
|
||||
"process.env.OTEL_EXPORTER_OTLP_ENDPOINT": JSON.stringify(
|
||||
env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
),
|
||||
"process.env.OTEL_EXPORTER_OTLP_HEADERS": JSON.stringify(
|
||||
env.OTEL_EXPORTER_OTLP_HEADERS,
|
||||
),
|
||||
"process.env.OTEL_METRIC_EXPORT_INTERVAL": JSON.stringify(
|
||||
env.OTEL_METRIC_EXPORT_INTERVAL,
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
"@components": resolve(__dirname, "./src/components"),
|
||||
"@context": resolve(__dirname, "./src/context"),
|
||||
"@shared": resolve(__dirname, "../src/shared"),
|
||||
"@utils": resolve(__dirname, "./src/utils"),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user