Compare commits

...

25 Commits

Author SHA1 Message Date
arafatkatze 1d9d96e1e5 locally running debug stuff 2025-07-16 12:00:41 -07:00
Sarah Fortune 8a2e90084d Change the port numbers for the ProtoBus service and HostBridge. (#4969)
Don't use the default gRPC port number because it's more likely to already be in use.
2025-07-16 11:28:07 -07:00
Sarah Fortune 4c3384988c open-file calls vscode.workspace.openTextDocument but doesn't use the result. (#4960) 2025-07-16 03:01:25 -07:00
Daniel Steigman 8539bdce24 Upgraded telemetry to capture each message turn separately (#4954)
* refactor: Ugraded telemetry to capture each message turn seperatly

* go back to the correct posthog key oops
2025-07-15 22:49:35 -07:00
Bee cd7f3ef6a7 Disable recording webview click events (#4709)
* Disable recording webview click events

Introduces a temporary measure to disable the recording of webview click events in PostHog. This is achieved by adding a `temporaryDisabled` flag that, when true, prevents the initialization of PostHog and stops the identification of users.

This change is intended to be temporary and should be reverted in a future commit by removing the `temporaryDisabled` flag.

* Use separate PostHog config for development environment

This commit introduces a separate PostHog project for the development environment. This allows us to track events in the development environment without polluting the production data.

The `posthogConfig` now uses `posthogDevEnvConfig` when `process.env.isDev` is true, and `posthogProdConfig` otherwise.

* process.env.IS_DEV

* Update src/shared/services/config/posthog-config.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fix format

---------

Co-authored-by: Beatrix Woo <beatrix@cline.bot>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-15 22:46:58 -07:00
github-actions[bot] 80fbcda03b v3.19.5 Release Notes (#4941)
-   Add Groq as a new API provider with support for all Groq models including Kimi-K2
-   Add user role display in organization UI for Cline account users
-   Fix message dialogs not showing option buttons properly
-   Fix authentication issues when using multiple VSCode windows
2025-07-15 21:49:06 -07:00
pashpashpash cef79e06db not showing request id when insufficient balance (#4953)
* not showing request id when insufficient balance

* whoops

* betterrr
2025-07-15 21:38:11 -07:00
Ara 5dddbef65c Adding Groq provider (#4943) 2025-07-15 19:47:56 -07:00
Bee b6f6358d4a Set up E2E tests with Playwright (#4721)
* Add Playwright E2E tests

Adding end-to-end (E2E) testing capabilities using Playwright. It also updates the `@vscode/test-electron` dependency.

The changes include:

- Adding Playwright as a dev dependency.
- Adding `e2e` and `e2e:build` scripts to `package.json` for running E2E tests.
- Adding `@playwright/test` to the list of dependencies.
- Updating `@vscode/test-electron` from `2.4.1` to `2.5.2`.
- Adding `test-results` to `.gitignore` to exclude test result files.

* wip: github workflow

Adding a new GitHub Actions workflow for running end-to-end (E2E) tests using Playwright. The workflow is triggered on push to the main branch, pull requests, and manual workflow dispatch.

The workflow defines a matrix strategy to run tests on different runners (Ubuntu and Windows) and shards. It also uploads Playwright recordings as artifacts if the tests fail.

* add @vscode/vsce as dev dep

* update workflow

* apply feedback

* fix test workflows

* add command palette helper

This commit improves the reliability and efficiency of the end-to-end tests by:

- Adding a delay to the "Let's go!" button click in the auth test to ensure the action is properly registered.
- Adding an expectation to ensure the "Get Started for Free" button is no longer visible after API key submission.
- Caching the "Use your own API key" button to avoid redundant lookups.
- Introducing a `runCommandPalette` helper function to streamline command execution within the VS Code environment.
- Disabling notifications before running the tests to prevent interference.

* state change

* set TEMP_PROFILE

* v3.18.7 Release Notes

* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.18.7

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>

* Remove optimistic loading from organization dropdown (#4746)

* update build script to javascript

* fix match

* Add mode switching to chat test

* expected

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
Co-authored-by: canvrno <46584286+canvrno@users.noreply.github.com>
2025-07-16 07:29:58 +05:30
Bee e607d02ab2 Fix: pass items to showMessage in VS Code host bridge (#4949) 2025-07-15 18:14:30 -07:00
Nick Baumann b5e2916bd6 docs: Update Claude Code documentation to include Pro plans alongside Max plans (#4948) 2025-07-15 17:00:45 -07:00
Sarah Fortune 371db77007 Move the vscode specific classes into the hosts/vscode package. (#4947)
* Move the vscode specific classes into the `hosts/vscode` package.

Move the vscode specific classes VscodeDiffViewProvider and VscodeWebviewProvider in the `hosts/vscode` package.

I am doing this so that the vscode-specific code is contained in one package instead of being mixed with the code that is meant to be platform-agnostic.

This also makes it easier for us to see which parts of the codebase are still using the vscode APIs and need to be migrated, and for the linter rules
that check that vscode API calls are not reintroduced after they are migrated to the host bridge.

* Use absolute imports instead of relative
2025-07-15 16:46:58 -07:00
Sarah Fortune 80f2e9f6ea Fix bad merge (#4946) 2025-07-15 16:26:12 -07:00
celestial-vault b46d396de2 add webview type checking to check-types (#4944) 2025-07-15 16:20:07 -07:00
Sarah Fortune 8683980c90 Move the vscode hostbridge handlers into their own package (#4945)
* Move the generated files for the host bridge into the 'src/generated' directory

Move the generated index.ts and methods.ts files for the host bridge into the 'src/generated' directory

I'm doing this because when all the generated files are one in location its a) easier to see from the import statement that the code is generated, b) it's easier to change the package(s) of the generated files, and c) easier to reset/clean the build state.

* Update import path

* Move the hostbridge handlers in the a hostbridge packge.

Move the hostbridge handlers out of the top level of the vscode package into their own subpackage.
I have to move all the vscode specific code into the `hosts/vscode` package, and I want the hostbridge handlers to be grouped together, not mixed in with things like the VscodeDiffViewProvider, VscodeWebviewProvider etc.
2025-07-15 16:12:48 -07:00
Bee b916e495e6 Remove credit validation from request (#4903)
Removes the credit validation check from the `createMessage` function in `src/api/providers/cline.ts`. The `validateRequest` function, which checks if the user has sufficient credits, has also been removed from `src/services/account/ClineAccountService.ts`.

The credit validation is no longer performed before sending a message to the Cline API.

Co-authored-by: abeatrix <beatrix@cline.bot>
2025-07-15 16:08:08 -07:00
Sarah Fortune d45077c4c5 Move the generated files for the host bridge into the 'src/generated' (#4942)
* Move the generated files for the host bridge into the 'src/generated' directory

Move the generated index.ts and methods.ts files for the host bridge into the 'src/generated' directory

I'm doing this because when all the generated files are one in location its a) easier to see from the import statement that the code is generated, b) it's easier to change the package(s) of the generated files, and c) easier to reset/clean the build state.

* Update import path
2025-07-15 16:00:02 -07:00
Bee 6ced4472d3 Capture provider API errors (#4936)
* Capture provider API errors

Introduces a new telemetry event to capture errors returned by API providers. This will allow us to better monitor the reliability and performance of different providers and identify potential issues.

The following changes were made:

- Added a `captureProviderApiError` method to the `TelemetryService` to record provider API errors.
- Added a new `PROVIDER_API_ERROR` event to the `TelemetryService.EVENTS.TASK` enum.
- Modified the `Task` class to capture and report provider API errors, including the error message, status code, and request ID.
- Added `extractErrorDetails` to extract the status code, message, and request ID from an error object.
- Updated `formatErrorWithStatusCode` to use `extractErrorDetails`.

* clean up

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
2025-07-15 15:37:25 -07:00
Bee dcb39a77f2 Display user role in organization (#4937)
* Display user role in organization

Adds a new feature to the Account View that displays the user's role within the currently selected organization.

- Added a `getMainRole` function to determine the user's primary role (Owner, Admin, or Member) based on the roles array.
- Display a VSCodeTag component showing the user's role next to the organization dropdown.
- Updated the organization dropdown to use className instead of style for width.

* changeset

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
2025-07-15 15:36:47 -07:00
Sarah Fortune 3301577934 Add diff.replaceText to the host bridge. (#4879)
Update the diff service to use a unique id to track open diff editor in external platforms.
Store the diff Id in the ExternalDiffViewEditor when the diff is opened. It will serve the same purpose as the activeDiffEditor property on vscode, it can be used to manipulate the diff editor tab.
Add the implementation of replaceText in the ExternalDiffViewEditor.
2025-07-15 14:50:23 -07:00
Bee a36c11eb97 Remove state parameter from auth callback (#4845)
* Remove state parameter from auth callback

Removes the state parameter that contains auth nonce and the associated logic.

The state parameter which contains the auth nonce in the auth callback doesn't work with multi-windows as each window contains its own nonce. As the provider parameter is sufficient to identify the auth provider we could remove the auth nonce to avoid complications.

* remove authNonce

* changeset

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-07-16 01:20:51 +05:30
Sarah Fortune 7d1f199883 Move the generated file hosts/vscode/host-grpc-service-config.ts in the src/generated directory. (#4938)
Rename some of the methods in the build-protos script to be more readable.
2025-07-15 12:39:21 -07:00
Sarah Fortune 6a1e0e518b Move the build-proto script into the scripts directory (#4935)
* Move the build-protos script into the scripts directory.

* Reorder imports
2025-07-15 12:04:04 -07:00
pashpashpash 004b313d20 Update diff edit evals README.md (#4920)
* Update README.md

* Update README.md
2025-07-16 00:17:14 +05:30
Saoud Rizwan bf37bfa7a3 Add vision capability to moonshot v1 (#4926) 2025-07-15 04:15:39 -07:00
84 changed files with 6053 additions and 785 deletions
+88
View File
@@ -0,0 +1,88 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
matrix_prep:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
echo 'matrix=[{"runner":"ubuntu"},{"runner":"windows"},{"runner":"macos"}]' >> $GITHUB_OUTPUT
e2e:
needs: matrix_prep
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
runs-on: ${{ matrix.runner }}-latest
timeout-minutes: 20
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 22
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Install xvfb on Linux
if: matrix.runner == 'ubuntu'
run: sudo apt-get update && sudo apt-get install -y xvfb
# Build the extension before running tests
- name: Build Tests and Extension
run: npm run pretest
- name: Run E2E tests - Linux
if: matrix.runner == 'ubuntu'
run: xvfb-run -a npm run test:e2e
- name: Run E2E tests - Non-Linux
if: matrix.runner != 'ubuntu'
run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: playwright-recordings-${{ matrix.runner }}
path: |
test-results/playwright/
+4
View File
@@ -68,6 +68,10 @@ jobs:
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Install xvfb on Linux
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Install local modules on windows
if: runner.os == 'Windows' && steps.root-cache.outputs.cache-hit == 'true'
run: |
+3 -3
View File
@@ -34,8 +34,8 @@ src/shared/proto/host/*.ts
# Webview
webview-ui/src/services/grpc-client.ts
# Host bridge
src/hosts/vscode/*/methods.ts
src/hosts/vscode/*/index.ts
src/hosts/vscode/client/host-grpc-client.ts
src/hosts/vscode/host-grpc-service-config.ts
src/standalone/server-setup.ts
# E2E Tests
test-results
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@vscode/test-cli"
import path from "path"
export default defineConfig({
files: "{out/**/*.test.js,src/**/*.test.js}",
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
mocha: {
ui: "bdd",
timeout: 20000, // Maximum time (in ms) that a test can run before failing
+21 -4
View File
@@ -1,15 +1,16 @@
# Default
.vscode/**
.vscode-test/**
out/**
dist-standalone/**
node_modules/**
out/
dist-standalone/
node_modules/
src/**
standalone/**
.gitignore
.yarnrc
esbuild.js
**/tsconfig.json
vsc-extension-quickstart.md
tsconfig*.json
**/.eslintrc.json
**/*.map
**/*.ts
@@ -23,6 +24,17 @@ eslint-rules/**
.nvmrc
.gitattributes
.prettierignore
.husky/
.github/
eslint-rules/
old_docs/
evals/
.changie.yaml
.codespellrc
.mocharc.json
buf.yaml
.changeset/
.clinerules/
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
webview-ui/src/**
@@ -47,3 +59,8 @@ old_docs/**
# Include icons
!assets/icons/**
# Ignore E2E build files
e2e-build.js
e2e.vsix
test-results/
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [3.19.5]
- Add Groq as a new API provider with support for all Groq models including Kimi-K2
- Add user role display in organization UI for Cline account users
- Fix message dialogs not showing option buttons properly
- Fix authentication issues when using multiple VSCode windows
## [3.19.4]
- Add ability to choose Chinese endpoint for Moonshot provider
+1 -1
View File
@@ -51,7 +51,7 @@ Thanks to [Claude 3.7 Sonnet's agentic coding capabilities](https://www.anthrop
### Use any API and Model
Cline supports API providers like OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex, and Cerebras. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using OpenRouter, the extension fetches their latest model list, allowing you to use the newest models as soon as they're available.
Cline supports API providers like OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex, Cerebras and Groq. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using OpenRouter, the extension fetches their latest model list, allowing you to use the newest models as soon as they're available.
The extension also keeps track of total tokens and API usage cost for the entire task loop and individual requests, keeping you informed of spend every step of the way.
+2 -2
View File
@@ -1,11 +1,11 @@
---
title: "Claude Code"
description: "Use your Claude Max subscription with Cline instead of paying per token. Learn how to set up and configure the Claude Code provider."
description: "Use your Claude Max or Pro subscription with Cline instead of paying per token. Learn how to set up and configure the Claude Code provider."
---
**Website:** [https://docs.anthropic.com/en/docs/claude-code/setup](https://docs.anthropic.com/en/docs/claude-code/setup)
The Claude Code provider lets you use your existing Claude subscription with Cline. If you have Claude Max, this means you can use Claude in Cline without paying extra API costs.
The Claude Code provider lets you use your existing Claude subscription with Cline. If you have Claude Max or Pro, this means you can use Claude in Cline without paying extra API costs.
<Frame>
<img
+12 -1
View File
@@ -5,6 +5,7 @@ const path = require("path")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
const standalone = process.argv.includes("--standalone")
const e2eBuild = process.argv.includes("--e2e-build")
const destDir = standalone ? "dist-standalone" : "dist"
/**
@@ -160,8 +161,18 @@ const standaloneConfig = {
external: ["vscode", "@grpc/reflection", "grpc-health-check"],
}
// E2E build script configuration
const e2eBuildConfig = {
...baseConfig,
entryPoints: ["src/test/e2e/utils/build.ts"],
outfile: `${destDir}/e2e-build.js`,
external: ["@vscode/test-electron", "execa"],
sourcemap: false,
plugins: [aliasResolverPlugin, esbuildProblemMatcherPlugin],
}
async function main() {
const config = standalone ? standaloneConfig : extensionConfig
const config = standalone ? standaloneConfig : e2eBuild ? e2eBuildConfig : extensionConfig
const extensionCtx = await esbuild.context(config)
if (watch) {
await extensionCtx.watch()
+1 -1
View File
@@ -36,7 +36,7 @@ It starts with our test cases. Each one is a JSON file in `./cases` that has the
Then, for every test run, we set up a specific configuration. This includes which LLM we're testing, which system prompt it gets, which function we use to parse the model's raw output, and which function we use to actually apply the diff. Here's the command I've been using:
```bash
npm run diff-eval -- --model-ids "anthropic/claude-3-5-sonnet-20241022,x-ai/grok-3-beta" --max-cases 4 --valid-attempts-per-case 2 --verbose --parallel
npm run diff-eval -- --model-ids "anthropic/claude-3-5-sonnet,x-ai/grok-3-beta,anthropic/claude-3.7-sonnet,anthropic/claude-sonnet-4,google/gemini-2.5-pro-preview,google/gemini-2.5-flash" --max-cases 5 --valid-attempts-per-case 5 --parallel --diff-edit-function diff-06-26-25 --verbose
```
This will build the eval script, run it, and then open the streamlit dashboard to show the results.
+3894 -127
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.19.4",
"version": "3.19.5",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -330,12 +330,12 @@
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.js --production",
"protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs",
"protos": "node scripts/build-proto.mjs && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs",
"postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --log-level warn",
"clean": "rimraf dist dist-standalone webview-ui/build src/generated out/",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"check-types": "npm run protos && tsc --noEmit",
"check-types": "npm run protos && npx tsc --noEmit && cd webview-ui && npx tsc -b --noEmit",
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts && buf lint && cd webview-ui && npm run lint",
"format": "prettier . --check",
"format:fix": "prettier . --write",
@@ -345,6 +345,8 @@
"test:integration": "vscode-test",
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
"test:coverage": "vscode-test --coverage",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e": "playwright install && vsce package --no-dependencies --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
@@ -383,7 +385,8 @@
"@typescript-eslint/parser": "^7.18.0",
"@typescript-eslint/utils": "^8.33.0",
"@vscode/test-cli": "^0.0.10",
"@vscode/test-electron": "^2.4.1",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.6.0",
"chai": "^4.3.10",
"chalk": "^5.3.0",
"esbuild": "^0.25.0",
@@ -425,6 +428,7 @@
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.0",
"@playwright/test": "^1.53.2",
"@sentry/browser": "^9.12.0",
"@streamparser/json": "^0.0.22",
"@vscode/codicons": "^0.0.36",
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test"
const isGitHubAction = !!process.env.CI
export default defineConfig({
workers: 1,
retries: 1,
testDir: "src/test/e2e",
timeout: 20000,
expect: {
timeout: 20000,
},
fullyParallel: true,
reporter: isGitHubAction ? [["github"], ["list"]] : [["list"]],
globalSetup: require.resolve("./src/test/e2e/utils/setup"),
globalTeardown: require.resolve("./src/test/e2e/utils/teardown"),
})
+15 -1
View File
@@ -10,6 +10,7 @@ import "common.proto";
service DiffService {
// Open the diff view/editor.
rpc openDiff(OpenDiffRequest) returns (OpenDiffResponse);
rpc replaceText(ReplaceTextRequest) returns (ReplaceTextResponse);
}
message OpenDiffRequest {
@@ -21,5 +22,18 @@ message OpenDiffRequest {
}
message OpenDiffResponse {
// TODO(sfortune) the host needs to return a unique id for the diff editor.
// A unique identifier for the diff view that was opened.
optional string diff_id = 1;
}
message ReplaceTextRequest {
optional cline.Metadata metadata = 1;
optional string diff_id = 2;
optional string content = 3;
optional int32 start_line = 4;
optional int32 end_line = 5;
}
message ReplaceTextResponse {
// TBD
}
+9 -3
View File
@@ -23,6 +23,8 @@ service ModelsService {
rpc subscribeToOpenRouterModels(EmptyRequest) returns (stream OpenRouterCompatibleModelInfo);
// Updates API configuration
rpc updateApiConfigurationProto(UpdateApiConfigurationRequest) returns (Empty);
// Refreshes and returns Groq models
rpc refreshGroqModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
}
// List of VS Code LM models
@@ -120,9 +122,10 @@ enum ApiProvider {
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
SAPAICORE = 24;
CLAUDE_CODE = 25;
MOONSHOT = 26;
GROQ = 24;
SAPAICORE = 25;
CLAUDE_CODE = 26;
MOONSHOT = 27;
}
// Model info for OpenAI-compatible models
@@ -240,4 +243,7 @@ message ModelsApiConfiguration {
optional string aws_bedrock_api_key = 75;
optional string moonshot_api_key = 76;
optional string moonshot_api_line = 77;
optional string groq_api_key = 78;
optional string groq_model_id = 79;
optional OpenRouterModelInfo groq_model_info = 80;
}
-3
View File
@@ -1,3 +0,0 @@
{
"type": "module"
}
@@ -9,22 +9,22 @@ import chalk from "chalk"
import os from "os"
import { createRequire } from "module"
import { serviceNameMap, hostServiceNameMap } from "./build-proto-config.js"
import { serviceNameMap, hostServiceNameMap } from "./build-proto-config.mjs"
const require = createRequire(import.meta.url)
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
const SCRIPT_NAME = path.relative(process.cwd(), fileURLToPath(import.meta.url))
const TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto")
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-js")
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "nice-grpc")
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone", "proto")
const PROTO_DIR = path.resolve("proto")
const TS_OUT_DIR = path.resolve("src/shared/proto")
const GRPC_JS_OUT_DIR = path.resolve("src/generated/grpc-js")
const NICE_JS_OUT_DIR = path.resolve("src/generated/nice-grpc")
const DESCRIPTOR_OUT_DIR = path.resolve("dist-standalone/proto")
const isWindows = process.platform === "win32"
const TS_PROTO_PLUGIN = isWindows
? path.join(ROOT_DIR, "node_modules", ".bin", "protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
: require.resolve("ts-proto/protoc-gen-ts_proto")
const TS_PROTO_OPTIONS = [
@@ -37,12 +37,7 @@ const TS_PROTO_OPTIONS = [
]
// Service directories derived from imported serviceNameMap
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey))
// Host service directories derived from imported hostServiceNameMap
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) =>
path.join(ROOT_DIR, "src", "hosts", "vscode", serviceKey),
)
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join("src/core/controller", serviceKey))
async function main() {
console.log(chalk.bold.blue("Starting Protocol Buffer code generation..."))
@@ -61,8 +56,8 @@ async function main() {
await ensureProtoFilesExist()
// Process all proto files
const protoFiles = await globby("**/*.proto", { cwd: SCRIPT_DIR, realpath: true })
console.log(chalk.cyan(`Processing ${protoFiles.length} proto files from`), SCRIPT_DIR)
const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR, realpath: true })
console.log(chalk.cyan(`Processing ${protoFiles.length} proto files from`), PROTO_DIR)
tsProtoc(TS_OUT_DIR, protoFiles, TS_PROTO_OPTIONS)
// grpc-js is used to generate service impls for the ProtoBus service.
@@ -73,7 +68,7 @@ async function main() {
const descriptorFile = path.join(DESCRIPTOR_OUT_DIR, "descriptor_set.pb")
const descriptorProtocCommand = [
PROTOC,
`--proto_path="${SCRIPT_DIR}"`,
`--proto_path="${PROTO_DIR}"`,
`--descriptor_set_out="${descriptorFile}"`,
"--include_imports",
...protoFiles,
@@ -89,11 +84,12 @@ async function main() {
log_verbose(chalk.green("Protocol Buffer code generation completed successfully."))
log_verbose(chalk.green(`TypeScript files generated in: ${TS_OUT_DIR}`))
await generateMethodRegistrations()
await generateHostMethodRegistrations()
await generateServiceConfig()
await generateHostServiceConfig()
await generateGrpcClientConfig()
await generateProtoBusServiceConfig()
await generateProtoBusMethodRegistrations()
await generateProtoBusGrpcClientConfig()
await generateHostBridgeServiceConfig()
await generateHostBridgeMethodRegistrations()
console.log(chalk.bold.blue("Finished Protocol Buffer code generation."))
}
@@ -102,7 +98,7 @@ async function tsProtoc(outDir, protoFiles, protoOptions) {
// Build the protoc command with proper path handling for cross-platform
const command = [
PROTOC,
`--proto_path="${SCRIPT_DIR}"`,
`--proto_path="${PROTO_DIR}"`,
`--plugin=protoc-gen-ts_proto="${TS_PROTO_PLUGIN}"`,
`--ts_proto_out="${outDir}"`,
`--ts_proto_opt=${protoOptions.join(",")} `,
@@ -122,7 +118,7 @@ async function tsProtoc(outDir, protoFiles, protoOptions) {
* Generate a gRPC client configuration file for the webview
* This eliminates the need for manual imports and client creation in grpc-client.ts
*/
async function generateGrpcClientConfig() {
async function generateProtoBusGrpcClientConfig() {
log_verbose(chalk.cyan("Generating gRPC client configuration..."))
const serviceImports = []
@@ -147,7 +143,7 @@ async function generateGrpcClientConfig() {
// Generate the file content
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
// Generated by ${SCRIPT_NAME}
import { createGrpcClient } from "./grpc-client-base"
${serviceImports.join("\n")}
@@ -158,7 +154,7 @@ export {
${serviceExports.join(",\n\t")}
}`
const filePath = path.join(ROOT_DIR, "webview-ui", "src", "services", "grpc-client.ts")
const filePath = path.resolve("webview-ui/src/services/grpc-client.ts")
await writeFileWithMkdirs(filePath, content)
log_verbose(chalk.green(`Generated gRPC client at ${filePath}`))
}
@@ -221,12 +217,12 @@ async function parseProtoForStreamingMethods(protoFiles, scriptDir) {
return streamingMethodsMap
}
async function generateMethodRegistrations() {
async function generateProtoBusMethodRegistrations() {
log_verbose(chalk.cyan("Generating method registration files..."))
// Parse proto files for streaming methods
const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR })
const streamingMethodsMap = await parseProtoForStreamingMethods(protoFiles, SCRIPT_DIR)
const protoFiles = await globby("*.proto", { cwd: PROTO_DIR })
const streamingMethodsMap = await parseProtoForStreamingMethods(protoFiles, PROTO_DIR)
for (const serviceDir of serviceDirs) {
const serviceName = path.basename(serviceDir)
@@ -243,7 +239,7 @@ async function generateMethodRegistrations() {
// Create the methods.ts file with header
let methodsContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
// Generated by ${SCRIPT_NAME}
// Import all method implementations
import { registerMethod } from "./index"\n`
@@ -292,7 +288,7 @@ export function registerAllMethods(): void {
// Generate index.ts file
const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1)
const indexContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
// Generated by ${SCRIPT_NAME}
import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service"
import { StreamingResponseHandler } from "../grpc-handler"
@@ -327,7 +323,7 @@ registerAllMethods()`
* Generate a service configuration file that maps service names to their handlers
* This eliminates the need for manual switch/case statements in grpc-handler.ts
*/
async function generateServiceConfig() {
async function generateProtoBusServiceConfig() {
log_verbose(chalk.cyan("Generating service configuration file..."))
const serviceImports = []
@@ -347,7 +343,7 @@ async function generateServiceConfig() {
}
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
// Generated by ${SCRIPT_NAME}
import { Controller } from "./index"
import { StreamingResponseHandler } from "./grpc-handler"
@@ -367,7 +363,7 @@ export interface ServiceHandlerConfig {
export const serviceHandlers: Record<string, ServiceHandlerConfig> = {${serviceConfigs.join(",")}
};`
const configPath = path.join(ROOT_DIR, "src", "core", "controller", "grpc-service-config.ts")
const configPath = path.resolve("src/core/controller/grpc-service-config.ts")
await writeFileWithMkdirs(configPath, content)
log_verbose(chalk.green(`Generated service configuration at ${configPath}`))
}
@@ -380,7 +376,7 @@ async function ensureProtoFilesExist() {
log_verbose(chalk.cyan("Checking for missing proto files..."))
// Get existing proto files
const existingProtoFiles = await globby("*.proto", { cwd: SCRIPT_DIR })
const existingProtoFiles = await globby("*.proto", { cwd: PROTO_DIR })
const existingProtoServices = existingProtoFiles.map((file) => path.basename(file, ".proto"))
// Check each service in serviceNameMap
@@ -417,7 +413,7 @@ service ${serviceClassName} {
`
// Write the template proto file
const protoFilePath = path.join(SCRIPT_DIR, `${serviceName}.proto`)
const protoFilePath = path.join(PROTO_DIR, `${serviceName}.proto`)
await fs.writeFile(protoFilePath, protoContent)
log_verbose(chalk.green(`Created template proto file at ${protoFilePath}`))
}
@@ -427,17 +423,22 @@ service ${serviceClassName} {
/**
* Generate method registration files for host services
*/
async function generateHostMethodRegistrations() {
async function generateHostBridgeMethodRegistrations() {
log_verbose(chalk.cyan("Generating host method registration files..."))
// Host service directories derived from imported hostServiceNameMap
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) =>
path.join("src/hosts/vscode/hostbridge", serviceKey),
)
// Parse proto files for streaming methods
const hostProtoFiles = await globby("*.proto", { cwd: path.join(SCRIPT_DIR, "host") })
const streamingMethodsMap = await parseProtoForStreamingMethods(hostProtoFiles, path.join(SCRIPT_DIR, "host"))
const hostProtoFiles = await globby("*.proto", { cwd: path.join(PROTO_DIR, "host") })
const streamingMethodsMap = await parseProtoForStreamingMethods(hostProtoFiles, path.join(PROTO_DIR, "host"))
for (const serviceDir of hostServiceDirs) {
const serviceName = path.basename(serviceDir)
const fullServiceName = hostServiceNameMap[serviceName]
const streamingMethods = streamingMethodsMap.get(fullServiceName) || []
const outputDir = path.join("src/generated/hosts/vscode/hostbridge", serviceName)
log_verbose(chalk.cyan(`Generating method registrations for host ${serviceName}...`))
@@ -449,7 +450,7 @@ async function generateHostMethodRegistrations() {
// Create the methods.ts file with header
let methodsContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
// Generated ${SCRIPT_NAME}
// Import all method implementations
import { registerMethod } from "./index"\n`
@@ -457,7 +458,7 @@ import { registerMethod } from "./index"\n`
// Import implementations directly
for (const file of implementationFiles) {
const baseName = path.basename(file, ".ts")
methodsContent += `import { ${baseName} } from "./${baseName}"\n`
methodsContent += `import { ${baseName} } from "@hosts/vscode/hostbridge/${serviceName}/${baseName}"\n`
}
// Add streaming methods information
@@ -491,17 +492,17 @@ export function registerAllMethods(): void {
methodsContent += `}`
// Write the methods.ts file
const registryFile = path.join(serviceDir, "methods.ts")
const registryFile = path.join(outputDir, "methods.ts")
await writeFileWithMkdirs(registryFile, methodsContent)
log_verbose(chalk.green(`Generated ${registryFile}`))
// Generate index.ts file
const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1)
const indexContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
// Generated by ${SCRIPT_NAME}
import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../host-grpc-service"
import { StreamingResponseHandler } from "../host-grpc-handler"
import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "@hosts/vscode/hostbridge-grpc-service"
import { StreamingResponseHandler } from "@hosts/vscode/hostbridge-grpc-handler"
import { registerAllMethods } from "./methods"
// Create ${serviceName} service registry
@@ -521,7 +522,7 @@ export const isStreamingMethod = ${serviceName}Service.isStreamingMethod
registerAllMethods()`
// Write the index.ts file
const indexFile = path.join(serviceDir, "index.ts")
const indexFile = path.join(outputDir, "index.ts")
await writeFileWithMkdirs(indexFile, indexContent)
log_verbose(chalk.green(`Generated ${indexFile}`))
}
@@ -532,7 +533,7 @@ registerAllMethods()`
/**
* Generate a service configuration file for host services
*/
async function generateHostServiceConfig() {
async function generateHostBridgeServiceConfig() {
log_verbose(chalk.cyan("Generating host service configuration file..."))
const serviceImports = []
@@ -542,7 +543,7 @@ async function generateHostServiceConfig() {
for (const [dirName, fullServiceName] of Object.entries(hostServiceNameMap)) {
const capitalizedName = dirName.charAt(0).toUpperCase() + dirName.slice(1)
serviceImports.push(
`import { handle${capitalizedName}ServiceRequest, handle${capitalizedName}ServiceStreamingRequest } from "./${dirName}/index"`,
`import { handle${capitalizedName}ServiceRequest, handle${capitalizedName}ServiceStreamingRequest } from "@generated/hosts/vscode/hostbridge/${dirName}/index"`,
)
serviceConfigs.push(`
"${fullServiceName}": {
@@ -552,9 +553,9 @@ async function generateHostServiceConfig() {
}
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
// Generated by ${SCRIPT_NAME}
import { StreamingResponseHandler } from "./host-grpc-handler"
import { StreamingResponseHandler } from "@/hosts/vscode/hostbridge-grpc-handler"
${serviceImports.join("\n")}
/**
@@ -571,7 +572,7 @@ export interface HostServiceHandlerConfig {
export const hostServiceHandlers: Record<string, HostServiceHandlerConfig> = {${serviceConfigs.join(",")}
};`
const filePath = path.join(ROOT_DIR, "src/hosts/vscode/host-grpc-service-config.ts")
const filePath = "src/generated/hosts/vscode/hostbridge-grpc-service-config.ts"
await writeFileWithMkdirs(filePath, content)
log_verbose(chalk.green(`Generated host service configuration at ${filePath}`))
}
@@ -583,15 +584,33 @@ async function cleanup() {
for (const file of existingFiles) {
await fs.unlink(path.join(TS_OUT_DIR, file))
}
await rmdir(path.join(ROOT_DIR, "src", "generated"))
await rmdir("src/generated")
// Clean up generated files that were moved.
await fs.rm(path.join(ROOT_DIR, "src", "standalone", "services", "host-grpc-client.ts"), { force: true })
await rmdir(path.join(ROOT_DIR, "src", "standalone", "services"))
await fs.rm(path.join(ROOT_DIR, "hosts", "vscode"), { force: true, recursive: true })
await rmdir(path.join(ROOT_DIR, "hosts"))
await fs.rm("src/standalone/services/host-grpc-client.ts", { force: true })
await rmdir("src/standalone/services")
await fs.rm("hosts/vscode", { force: true, recursive: true })
await rmdir("hosts")
await fs.rm(path.join(ROOT_DIR, "src/standalone/server-setup.ts"), { force: true })
await fs.rm("src/standalone/server-setup.ts", { force: true })
await fs.rm("src/hosts/vscode/host-grpc-service-config.ts", { force: true })
const oldhostbridgefiles = [
"src/hosts/vscode/workspace/methods.ts",
"src/hosts/vscode/workspace/index.ts",
"src/hosts/vscode/diff/methods.ts",
"src/hosts/vscode/diff/index.ts",
"src/hosts/vscode/env/methods.ts",
"src/hosts/vscode/env/index.ts",
"src/hosts/vscode/window/methods.ts",
"src/hosts/vscode/window/index.ts",
"src/hosts/vscode/watch/methods.ts",
"src/hosts/vscode/watch/index.ts",
"src/hosts/vscode/uri/methods.ts",
"src/hosts/vscode/uri/index.ts",
]
for (const file of oldhostbridgefiles) {
await fs.rm(file, { force: true })
}
}
/**
+8
View File
@@ -28,6 +28,7 @@ import { CerebrasHandler } from "./providers/cerebras"
import { SapAiCoreHandler } from "./providers/sapaicore"
import { ClaudeCodeHandler } from "./providers/claude-code"
import { MoonshotHandler } from "./providers/moonshot"
import { GroqHandler } from "./providers/groq"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
@@ -221,6 +222,13 @@ function createHandlerForProvider(apiProvider: string | undefined, options: Omit
cerebrasApiKey: options.cerebrasApiKey,
apiModelId: options.apiModelId,
})
case "groq":
return new GroqHandler({
groqApiKey: options.groqApiKey,
groqModelId: options.groqModelId,
groqModelInfo: options.groqModelInfo,
apiModelId: options.apiModelId,
})
case "sapaicore":
return new SapAiCoreHandler({
sapAiCoreClientId: options.sapAiCoreClientId,
+2 -9
View File
@@ -29,7 +29,7 @@ export class ClineHandler implements ApiHandler {
private _authService: AuthService
private client: OpenAI | undefined
// TODO: replace this with a global API Host
private readonly _baseUrl = "https://api.cline.bot"
private readonly _baseUrl = "http://localhost:7777"
// private readonly _baseUrl = "https://core-api.staging.int.cline.bot"
// private readonly _baseUrl = "http://localhost:7777"
lastGenerationId?: string
@@ -69,12 +69,6 @@ export class ClineHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
try {
// Only continue the request if the user:
// 1. Has signed in to Cline with a token
// 2. Has more than 0 credits
// Or an error is thrown.
await this.clineAccountService.validateRequest()
const client = await this.ensureClient()
this.lastGenerationId = undefined
@@ -185,12 +179,11 @@ export class ClineHandler implements ApiHandler {
}
} catch (error) {
console.error("Cline API Error:", error)
const requestId = error?.request_id ? ` (Request ID: ${error.request_id})` : ""
const requestId = error?.request_id ? `\n | Request ID: ${error.request_id}` : ""
if (error.code === "ERR_BAD_REQUEST" || error.status === 401) {
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE + requestId)
} else if (error.code === "insufficient_credits" || error.status === 402) {
if (error.error) {
error.error.message = error.error.message + requestId
throw new Error(JSON.stringify(error.error))
}
}
+12 -17
View File
@@ -256,23 +256,18 @@ export class GeminiHandler implements ApiHandler {
totalDurationSdkMs > 0 && outputTokens > 0 ? outputTokens / (totalDurationSdkMs / 1000) : undefined
if (this.options.taskId) {
telemetryService.captureGeminiApiPerformance(
this.options.taskId,
modelId,
{
ttftSec: ttftSdkMs !== undefined ? ttftSdkMs / 1000 : undefined,
totalDurationSec: totalDurationSdkMs / 1000,
promptTokens,
outputTokens,
cacheReadTokens,
cacheHit,
cacheHitPercentage,
apiSuccess,
apiError,
throughputTokensPerSec: throughputTokensPerSecSdk,
},
true,
)
telemetryService.captureGeminiApiPerformance(this.options.taskId, modelId, {
ttftSec: ttftSdkMs !== undefined ? ttftSdkMs / 1000 : undefined,
totalDurationSec: totalDurationSdkMs / 1000,
promptTokens,
outputTokens,
cacheReadTokens,
cacheHit,
cacheHitPercentage,
apiSuccess,
apiError,
throughputTokensPerSec: throughputTokensPerSecSdk,
})
} else {
console.warn("GeminiHandler: taskId not available for telemetry in createMessage.")
}
+290
View File
@@ -0,0 +1,290 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { GroqModelId, ModelInfo, groqDefaultModelId, groqModels } from "@shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
interface GroqHandlerOptions {
groqApiKey?: string
groqModelId?: string
groqModelInfo?: ModelInfo
apiModelId?: string // For backward compatibility
}
// Model family definitions for enhanced behavior
interface GroqModelFamily {
name: string
supportedFeatures: {
streaming: boolean
temperature: boolean
vision: boolean
tools: boolean
}
maxTokensOverride?: number
specialParams?: Record<string, any>
}
const MODEL_FAMILIES: Record<string, GroqModelFamily> = {
// Moonshort 4 Family - Latest generation with vision support
"kimi-k2": {
name: "kimi-k2",
supportedFeatures: { streaming: true, temperature: true, vision: true, tools: true },
maxTokensOverride: 8192,
},
// Llama 4 Family - Latest generation with vision support
llama4: {
name: "Llama 4",
supportedFeatures: { streaming: true, temperature: true, vision: true, tools: true },
maxTokensOverride: 8192,
},
// Llama 3.3 Family - Balanced performance
"llama3.3": {
name: "Llama 3.3",
supportedFeatures: { streaming: true, temperature: true, vision: false, tools: true },
maxTokensOverride: 32768,
},
// Llama 3.1 Family - Fast inference
"llama3.1": {
name: "Llama 3.1",
supportedFeatures: { streaming: true, temperature: true, vision: false, tools: true },
maxTokensOverride: 131072,
},
// DeepSeek Family - Reasoning-optimized
deepseek: {
name: "DeepSeek",
supportedFeatures: { streaming: true, temperature: true, vision: false, tools: true },
maxTokensOverride: 8192,
specialParams: {
top_p: 0.95,
reasoning_format: "parsed",
},
},
// Qwen Family - Enhanced for Q&A
qwen: {
name: "Qwen",
supportedFeatures: { streaming: true, temperature: true, vision: false, tools: true },
maxTokensOverride: 32768,
},
// Compound Models - Hybrid architectures
compound: {
name: "Compound",
supportedFeatures: { streaming: true, temperature: true, vision: false, tools: true },
maxTokensOverride: 8192,
},
}
export class GroqHandler implements ApiHandler {
private options: GroqHandlerOptions
private client: OpenAI | undefined
constructor(options: GroqHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.groqApiKey) {
throw new Error("Groq API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.groq.com/openai/v1",
apiKey: this.options.groqApiKey,
})
} catch (error) {
throw new Error(`Error creating Groq client: ${error.message}`)
}
}
return this.client
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
const inputTokens = usage?.prompt_tokens || 0
const outputTokens = usage?.completion_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens)
yield {
type: "usage",
inputTokens,
outputTokens,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost,
}
}
/**
* Detects the model family based on the model ID
*/
private detectModelFamily(modelId: string): GroqModelFamily {
if (modelId.includes("kimi-k2")) {
return MODEL_FAMILIES["kimi-k2"]
}
// Llama 4 variants
if (modelId.includes("llama-4") || modelId.includes("llama/llama-4")) {
return MODEL_FAMILIES.llama4
}
// Llama 3.3 variants
if (modelId.includes("llama-3.3")) {
return MODEL_FAMILIES["llama3.3"]
}
// Llama 3.1 variants
if (modelId.includes("llama-3.1")) {
return MODEL_FAMILIES["llama3.1"]
}
// DeepSeek variants
if (modelId.includes("deepseek")) {
return MODEL_FAMILIES.deepseek
}
// Qwen variants
if (modelId.includes("qwen")) {
return MODEL_FAMILIES.qwen
}
// Compound variants
if (modelId.includes("compound")) {
return MODEL_FAMILIES.compound
}
// Default fallback to Llama 3.3 behavior
return MODEL_FAMILIES["kimi-k2"]
}
/**
* Gets the optimal max_tokens based on model family and capabilities
*/
private getOptimalMaxTokens(model: { id: string; info: ModelInfo }, modelFamily: GroqModelFamily): number {
// Use model-specific max tokens if available
if (model.info.maxTokens && model.info.maxTokens > 0) {
return model.info.maxTokens
}
// Use family override if available
if (modelFamily.maxTokensOverride) {
return modelFamily.maxTokensOverride
}
// Default fallback
return 8192
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const modelFamily = this.detectModelFamily(model.id)
// Optimize parameters based on model family
const temperature = 0
const maxTokens = this.getOptimalMaxTokens(model, modelFamily)
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// Build request parameters with model-specific optimizations
const requestParams: OpenAI.Chat.ChatCompletionCreateParamsStreaming & {
reasoning_format?: "parsed" | "raw" | "hidden"
top_p?: number
} = {
model: model.id,
max_tokens: maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature,
}
// Add any special parameters for specific model families
if (modelFamily.specialParams) {
Object.assign(requestParams, modelFamily.specialParams)
}
const stream = await client.chat.completions.create(requestParams)
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
// Handle reasoning field if present (for reasoning models with parsed output)
if ((delta as any)?.reasoning) {
const reasoningContent = (delta as any).reasoning as string
yield {
type: "reasoning",
reasoning: reasoningContent,
}
continue
}
// Handle content field - trust the parsed output from Groq
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// Handle usage information
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
}
}
}
/**
* Checks if the current model supports vision/images
*/
supportsImages(): boolean {
const model = this.getModel()
return model.info.supportsImages === true
}
/**
* Checks if the current model supports tools
*/
supportsTools(): boolean {
const model = this.getModel()
const modelFamily = this.detectModelFamily(model.id)
return modelFamily.supportedFeatures.tools
}
/**
* Gets model information with enhanced family detection
*/
getModel(): { id: string; info: ModelInfo } {
// First priority: groqModelId and groqModelInfo (like Requesty does)
const groqModelId = this.options.groqModelId
const groqModelInfo = this.options.groqModelInfo
if (groqModelId && groqModelInfo) {
return { id: groqModelId, info: groqModelInfo }
}
// Second priority: groqModelId with static model info
if (groqModelId && groqModelId in groqModels) {
const id = groqModelId as GroqModelId
return { id, info: groqModels[id] }
}
// Third priority: apiModelId (for backward compatibility)
const apiModelId = this.options.apiModelId
if (apiModelId && apiModelId in groqModels) {
const id = apiModelId as GroqModelId
return { id, info: groqModels[id] }
}
// Default fallback
return {
id: groqDefaultModelId,
info: groqModels[groqDefaultModelId],
}
}
/**
* Gets model family information for debugging/introspection
*/
getModelFamily(): GroqModelFamily {
const model = this.getModel()
return this.detectModelFamily(model.id)
}
}
@@ -8,7 +8,7 @@ import * as diskModule from "@core/storage/disk"
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
import type { DiffViewProviderCreator, WebviewProviderCreator } from "@/hosts/host-providers"
import * as hostProviders from "@hosts/host-providers"
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
-6
View File
@@ -461,11 +461,6 @@ export class Controller {
}
}
// Auth
public async validateAuthState(state: string | null): Promise<boolean> {
return state === this.authService.authNonce
}
async handleAuthCallback(customToken: string, provider: string | null = null) {
try {
await this.authService.handleAuthCallback(customToken, provider ? provider : "google")
@@ -910,7 +905,6 @@ export class Controller {
async clearTask() {
if (this.task) {
await telemetryService.sendCollectedEvents(this.task.taskId)
}
await this.task?.abortTask()
this.task = undefined // removes reference to it, so once promises end it will be garbage collected
@@ -0,0 +1,254 @@
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "../../../shared/proto/models"
import { getAllExtensionState } from "../../storage/state"
import { groqModels } from "../../../shared/api"
import axios from "axios"
import path from "path"
import fs from "fs/promises"
import { fileExistsAtPath } from "@utils/fs"
import { GlobalFileNames } from "@core/storage/disk"
/**
* Refreshes the Groq models and returns the updated model list
* @param controller The controller instance
* @param request Empty request object
* @returns Response containing the Groq models
*/
export async function refreshGroqModels(controller: Controller, request: EmptyRequest): Promise<OpenRouterCompatibleModelInfo> {
const groqModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.groqModels)
// Get the Groq API key from the controller's state
const { apiConfiguration } = await getAllExtensionState(controller.context)
const groqApiKey = apiConfiguration?.groqApiKey
let models: Record<string, Partial<OpenRouterModelInfo>> = {}
try {
if (!groqApiKey) {
console.log("No Groq API key found, using static models as fallback")
// Don't throw an error, just use static models
for (const [modelId, modelInfo] of Object.entries(groqModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
description: modelInfo.description || `${modelId} model`,
}
}
} else {
// Ensure the API key is properly formatted
const cleanApiKey = groqApiKey.trim()
if (!cleanApiKey.startsWith("gsk_")) {
throw new Error("Invalid Groq API key format. Groq API keys should start with 'gsk_'")
}
console.log("Fetching Groq models with API key:", cleanApiKey.substring(0, 10) + "...")
const response = await axios.get("https://api.groq.com/openai/v1/models", {
headers: {
Authorization: `Bearer ${cleanApiKey}`,
"Content-Type": "application/json",
"User-Agent": "Cline-VSCode-Extension",
},
timeout: 10000, // 10 second timeout
})
if (response.data?.data) {
const rawModels = response.data.data
for (const rawModel of rawModels) {
// Filter out non-chat models and validate model capabilities
if (!isValidChatModel(rawModel)) {
continue
}
// Check if we have static pricing information for this model
const staticModelInfo = groqModels[rawModel.id as keyof typeof groqModels]
const modelInfo: Partial<OpenRouterModelInfo> = {
maxTokens: rawModel.max_completion_tokens || staticModelInfo?.maxTokens || 8192,
contextWindow: rawModel.context_window || staticModelInfo?.contextWindow || 8192,
supportsImages: detectImageSupport(rawModel, staticModelInfo),
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
inputPrice: staticModelInfo?.inputPrice || 0,
outputPrice: staticModelInfo?.outputPrice || 0,
cacheWritesPrice: (staticModelInfo as any)?.cacheWritesPrice || 0,
cacheReadsPrice: (staticModelInfo as any).cacheReadsPrice || 0,
description: generateModelDescription(rawModel, staticModelInfo),
}
models[rawModel.id] = modelInfo
}
} else {
console.error("Invalid response from Groq API")
}
await fs.writeFile(groqModelsFilePath, JSON.stringify(models))
console.log("Groq models fetched and saved", models)
}
} catch (error) {
console.error("Error fetching Groq models:", error)
// Provide more specific error messages
let errorMessage = "Unknown error occurred"
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
errorMessage = "Invalid Groq API key. Please check your API key in settings."
} else if (error.response?.status === 403) {
errorMessage = "Access forbidden. Please verify your Groq API key has the correct permissions."
} else if (error.response?.status === 429) {
errorMessage = "Rate limit exceeded. Please try again later."
} else if (error.code === "ECONNABORTED") {
errorMessage = "Request timeout. Please check your internet connection."
} else {
errorMessage = `API request failed: ${error.response?.status || error.code || "Unknown error"}`
}
} else if (error instanceof Error) {
errorMessage = error.message
}
console.error("Groq API Error:", errorMessage)
// If we failed to fetch models, try to read cached models first
const cachedModels = await readGroqModels(controller)
if (cachedModels && Object.keys(cachedModels).length > 0) {
console.log("Using cached Groq models")
models = cachedModels
} else {
// Fall back to static models from shared/api.ts
console.log("Using static Groq models as fallback")
for (const [modelId, modelInfo] of Object.entries(groqModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
description: modelInfo.description || `${modelId} model`,
}
}
}
}
// Convert the Record<string, Partial<OpenRouterModelInfo>> to Record<string, OpenRouterModelInfo>
// by filling in any missing required fields with defaults
const typedModels: Record<string, OpenRouterModelInfo> = {}
for (const [key, model] of Object.entries(models)) {
typedModels[key] = {
maxTokens: model.maxTokens ?? 8192,
contextWindow: model.contextWindow ?? 8192,
supportsImages: model.supportsImages ?? false,
supportsPromptCache: model.supportsPromptCache ?? false,
inputPrice: model.inputPrice ?? 0,
outputPrice: model.outputPrice ?? 0,
cacheWritesPrice: model.cacheWritesPrice ?? 0,
cacheReadsPrice: model.cacheReadsPrice ?? 0,
description: model.description ?? "",
tiers: model.tiers ?? [],
}
}
return OpenRouterCompatibleModelInfo.create({ models: typedModels })
}
/**
* Reads cached Groq models from disk
*/
async function readGroqModels(controller: Controller): Promise<Record<string, Partial<OpenRouterModelInfo>> | undefined> {
const groqModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.groqModels)
const fileExists = await fileExistsAtPath(groqModelsFilePath)
if (fileExists) {
try {
const fileContents = await fs.readFile(groqModelsFilePath, "utf8")
return JSON.parse(fileContents)
} catch (error) {
console.error("Error reading cached Groq models:", error)
return undefined
}
}
return undefined
}
/**
* Validates if a model is suitable for chat completions
*/
function isValidChatModel(rawModel: any): boolean {
// Check if model is active (if the property exists)
if (rawModel.hasOwnProperty("active") && !rawModel.active) {
return false
}
// Filter out non-chat models (whisper, TTS, guard models, etc.)
if (
rawModel.id.includes("whisper") ||
rawModel.id.includes("tts") ||
rawModel.id.includes("guard") ||
rawModel.id.includes("embedding") ||
rawModel.id.includes("moderation") ||
rawModel.id.includes("allam")
) {
return false
}
// Check if model supports chat completions
if (rawModel.object === "model" && rawModel.id) {
return true
}
return false
}
/**
* Detects if a model supports image input
*/
function detectImageSupport(rawModel: any, staticModelInfo?: any): boolean {
// Use static info if available
if (staticModelInfo?.supportsImages !== undefined) {
return staticModelInfo.supportsImages
}
// Detect based on model name patterns
const modelId = rawModel.id.toLowerCase()
if (modelId.includes("vision") || modelId.includes("maverick") || modelId.includes("scout")) {
return true
}
return false
}
/**
* Generates a descriptive name for the model
*/
function generateModelDescription(rawModel: any, staticModelInfo?: any): string {
// Use static description if available
if (staticModelInfo?.description) {
return staticModelInfo.description
}
// Generate description based on model characteristics
const modelId = rawModel.id
const contextWindow = rawModel.context_window || 8192
const ownedBy = rawModel.owned_by || "Unknown"
// Special handling for new models
if (modelId.includes("compound")) {
return `${ownedBy}'s ${modelId} model with ${contextWindow.toLocaleString()} token context window - Advanced compound architecture`
}
return `${ownedBy} model with ${contextWindow.toLocaleString()} token context window`
}
/**
* Ensures the cache directory exists and returns its path
*/
async function ensureCacheDirectoryExists(controller: Controller): Promise<string> {
const cacheDir = path.join(controller.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
@@ -42,6 +42,17 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
}
})
handleModelsServiceRequest(controller, "refreshGroqModels", EmptyRequest.create()).then(async (response) => {
if (response && response.models) {
// update model info in state for Groq
const { apiConfiguration } = await getAllExtensionState(controller.context)
if (apiConfiguration.groqModelId && response.models[apiConfiguration.groqModelId]) {
await updateGlobalState(controller.context, "groqModelInfo", response.models[apiConfiguration.groqModelId])
await controller.postStateToWebview()
}
}
})
// GUI relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
// We do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
// (see normalizeApiConfiguration > openrouter)
+1
View File
@@ -13,6 +13,7 @@ export const GlobalFileNames = {
contextHistory: "context_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
groqModels: "groq_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
workflows: ".clinerules/workflows",
+3
View File
@@ -26,6 +26,7 @@ export type SecretKey =
| "cerebrasApiKey"
| "sapAiCoreClientId"
| "sapAiCoreClientSecret"
| "groqApiKey"
export type GlobalStateKey =
| "awsRegion"
@@ -117,5 +118,7 @@ export type GlobalStateKey =
| "previousModeAwsBedrockCustomSelected"
| "previousModeAwsBedrockCustomModelBaseId"
| "previousModeSapAiCoreModelId"
| "groqModelId"
| "groqModelInfo"
export type LocalStateKey = "localClineRulesToggles" | "localCursorRulesToggles" | "localWindsurfRulesToggles" | "workflowToggles"
+17
View File
@@ -167,6 +167,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
moonshotApiKey,
nebiusApiKey,
planActSeparateModelsSettingRaw,
@@ -188,6 +189,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
groqModelId,
groqModelInfo,
] = await Promise.all([
getGlobalState(context, "isNewUser") as Promise<boolean | undefined>,
getGlobalState(context, "welcomeViewCompleted") as Promise<boolean | undefined>,
@@ -244,6 +247,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "xaiApiKey") as Promise<string | undefined>,
getSecret(context, "sambanovaApiKey") as Promise<string | undefined>,
getSecret(context, "cerebrasApiKey") as Promise<string | undefined>,
getSecret(context, "groqApiKey") as Promise<string | undefined>,
getSecret(context, "moonshotApiKey") as Promise<string | undefined>,
getSecret(context, "nebiusApiKey") as Promise<string | undefined>,
getGlobalState(context, "planActSeparateModelsSetting") as Promise<boolean | undefined>,
@@ -265,6 +269,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "sapAiCoreTokenUrl") as Promise<string | undefined>,
getGlobalState(context, "sapAiResourceGroup") as Promise<string | undefined>,
getGlobalState(context, "claudeCodePath") as Promise<string | undefined>,
getGlobalState(context, "groqModelId") as Promise<string | undefined>,
getGlobalState(context, "groqModelInfo") as Promise<ModelInfo | undefined>,
])
const localClineRulesToggles = (await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles
@@ -339,6 +345,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
const processingStart = performance.now()
let apiProvider: ApiProvider
if (storedApiProvider) {
// Use the explicitly stored provider - this respects user's selection
apiProvider = storedApiProvider
} else {
// Either new user or legacy user that doesn't have the apiProvider stored in state
@@ -442,6 +449,9 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
groqModelId,
groqModelInfo,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
@@ -554,6 +564,9 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
clineAccountId,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
groqModelId,
groqModelInfo,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
@@ -592,6 +605,8 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
requestyModelInfo,
togetherModelId,
fireworksModelId,
groqModelId,
groqModelInfo,
sapAiCoreModelId,
// Global state updates (27 keys)
@@ -652,6 +667,7 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
moonshotApiKey,
nebiusApiKey,
sapAiCoreClientId,
@@ -696,6 +712,7 @@ export async function resetGlobalState(context: vscode.ExtensionContext) {
"xaiApiKey",
"sambanovaApiKey",
"cerebrasApiKey",
"groqApiKey",
"moonshotApiKey",
"nebiusApiKey",
]
+15 -5
View File
@@ -81,7 +81,7 @@ import { refreshWorkflowToggles } from "../context/instructions/user-instruction
import { MessageStateHandler } from "./message-state"
import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
import { formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
import { extractErrorDetails, formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
import { createDiffViewProvider, getHostBridgeProvider } from "@/hosts/host-providers"
export const USE_EXPERIMENTAL_CLAUDE4_FEATURES = false
@@ -1575,8 +1575,9 @@ export class Task {
await this.migrateDisableBrowserToolSetting()
const disableBrowserTool = this.browserSettings.disableToolUse ?? false
const modelInfo = this.api.getModel()
// cline browser tool uses image recognition for navigation (requires model image support).
const modelSupportsBrowserUse = this.api.getModel().info.supportsImages ?? false
const modelSupportsBrowserUse = modelInfo.info.supportsImages ?? false
const supportsBrowserUse = modelSupportsBrowserUse && !disableBrowserTool // only enable browser use if the model supports it and the user hasn't disabled it
@@ -1661,6 +1662,17 @@ export class Task {
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
const { statusCode, message, requestId } = extractErrorDetails(error)
// Capture provider failure telemetry
telemetryService.captureProviderApiError({
taskId: this.taskId,
model: modelInfo.id,
errorMessage: message,
errorStatus: statusCode,
requestId,
})
if (isAnthropic && isAnthropicContextWindowError && !this.taskState.didAutomaticallyRetryFailedApiRequest) {
this.taskState.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
this.messageStateHandler.getApiConversationHistory(),
@@ -2054,7 +2066,7 @@ export class Task {
content: userContent,
})
telemetryService.captureConversationTurnEvent(this.taskId, currentProviderId, this.api.getModel().id, "user", true)
telemetryService.captureConversationTurnEvent(this.taskId, currentProviderId, this.api.getModel().id, "user")
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
const lastApiReqIndex = findLastIndex(this.messageStateHandler.getClineMessages(), (m) => m.say === "api_req_started")
@@ -2124,7 +2136,6 @@ export class Task {
currentProviderId,
this.api.getModel().id,
"assistant",
true,
{
tokensIn: inputTokens,
tokensOut: outputTokens,
@@ -2304,7 +2315,6 @@ export class Task {
currentProviderId,
this.api.getModel().id,
"assistant",
true,
{
tokensIn: inputTokens,
tokensOut: outputTokens,
+9 -2
View File
@@ -6,13 +6,20 @@ import { calculateApiCostAnthropic } from "@/utils/cost"
import { ApiHandler } from "@/api"
export function formatErrorWithStatusCode(error: any): string {
const statusCode = error.status || error.statusCode || (error.response && error.response.status)
const message = error.message ?? JSON.stringify(serializeError(error), null, 2)
const { statusCode, message } = extractErrorDetails(error)
// Only prepend the statusCode if it's not already part of the message
return statusCode && !message.includes(statusCode.toString()) ? `${statusCode} - ${message}` : message
}
export function extractErrorDetails(error: any): { message: string; statusCode?: number; requestId?: string } {
const statusCode = error.status || error.statusCode || (error.response && error.response?.status)
const message = error.message ?? JSON.stringify(serializeError(error), null, 2)
const requestId = error.request_id || error.response?.request_id || undefined
return { message, statusCode, requestId }
}
export const showNotificationForApprovalIfAutoApprovalEnabled = (
message: string,
autoApprovalSettingsEnabled: boolean,
+10 -7
View File
@@ -262,13 +262,16 @@ export abstract class WebviewProvider {
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message:
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
}),
)
// Only show the error message if not in development mode.
if (!process.env.IS_DEV) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message:
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
}),
)
}
return this.getHtmlContent()
}
+10 -35
View File
@@ -32,12 +32,12 @@ import {
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import * as hostProviders from "@hosts/host-providers"
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
import { VscodeWebviewProvider } from "./core/webview/VscodeWebviewProvider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { ExtensionContext } from "vscode"
import { AuthService } from "./services/auth/AuthService"
import { writeTextToClipboard, readTextFromClipboard } from "@/utils/env"
import { VscodeDiffViewProvider } from "./integrations/editor/VscodeDiffViewProvider"
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import { getHostBridgeProvider } from "@hosts/host-providers"
import { ShowMessageRequest, ShowMessageType } from "./shared/proto/host/window"
/*
@@ -301,35 +301,12 @@ export async function activate(context: vscode.ExtensionContext) {
break
}
case "/auth": {
const authService = AuthService.getInstance()
console.log("Auth callback received:", uri.toString())
const token = query.get("idToken")
const state = query.get("state")
const provider = query.get("provider")
console.log("Auth callback received:", {
token: token,
state: state,
provider: provider,
})
// Ask user to confirm on state mismatch. This enables signins initiated from
// outside the extension (e.g. Cline web) to be handled correctly.
if (authService.authNonce !== state) {
const userConfirmation = (
await getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Invalid auth state",
}),
)
)?.selectedOption
if (userConfirmation === "Cancel") {
console.log("User declined to continue with auth callback due to state mismatch")
return
}
}
console.log("Auth callback received:", { provider })
if (token) {
await visibleWebview?.controller.handleAuthCallback(token, provider)
@@ -386,7 +363,7 @@ export async function activate(context: vscode.ExtensionContext) {
languageId,
Array.isArray(diagnostics) ? diagnostics : undefined,
)
telemetryService.captureButtonClick("codeAction_addToChat", visibleWebview?.controller.task?.taskId, true)
telemetryService.captureButtonClick("codeAction_addToChat", visibleWebview?.controller.task?.taskId)
}),
)
@@ -563,7 +540,7 @@ export async function activate(context: vscode.ExtensionContext) {
// Send to sidebar provider with diagnostics
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.fixWithCline(selectedText, filePath, languageId, diagnostics)
telemetryService.captureButtonClick("codeAction_fixWithCline", visibleWebview?.controller.task?.taskId, true)
telemetryService.captureButtonClick("codeAction_fixWithCline", visibleWebview?.controller.task?.taskId)
}),
)
@@ -590,7 +567,7 @@ export async function activate(context: vscode.ExtensionContext) {
const fileMention = visibleWebview?.controller.getFileMentionFromPath(filePath) || filePath
const prompt = `Explain the following code from ${fileMention}:\n\`\`\`${editor.document.languageId}\n${selectedText}\n\`\`\``
await visibleWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_explainCode", visibleWebview?.controller.task?.taskId, true)
telemetryService.captureButtonClick("codeAction_explainCode", visibleWebview?.controller.task?.taskId)
}),
)
@@ -617,7 +594,7 @@ export async function activate(context: vscode.ExtensionContext) {
const fileMention = visibleWebview?.controller.getFileMentionFromPath(filePath) || filePath
const prompt = `Improve the following code from ${fileMention} (e.g., suggest refactorings, optimizations, or better practices):\n\`\`\`${editor.document.languageId}\n${selectedText}\n\`\`\``
await visibleWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_improveCode", visibleWebview?.controller.task?.taskId, true)
telemetryService.captureButtonClick("codeAction_improveCode", visibleWebview?.controller.task?.taskId)
}),
)
@@ -681,7 +658,7 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
}
telemetryService.captureButtonClick("command_focusChatInput", activeWebviewProvider?.controller.task?.taskId, true)
telemetryService.captureButtonClick("command_focusChatInput", activeWebviewProvider?.controller.task?.taskId)
}),
)
@@ -689,7 +666,7 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("cline.openWalkthrough", async () => {
await vscode.commands.executeCommand("workbench.action.openWalkthrough", "saoudrizwan.claude-dev#ClineWalkthrough")
telemetryService.captureButtonClick("command_openWalkthrough", undefined, true)
telemetryService.captureButtonClick("command_openWalkthrough")
}),
)
@@ -751,8 +728,6 @@ export async function deactivate() {
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
await telemetryService.sendCollectedEvents()
// Clean up test mode
cleanupTestMode()
await posthogClientProvider.shutdown()
@@ -1,8 +1,8 @@
import { arePathsEqual } from "@/utils/path"
import * as path from "path"
import * as vscode from "vscode"
import { DecorationController } from "./DecorationController"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "./DiffViewProvider"
import { DecorationController } from "@integrations/editor/DecorationController"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "@integrations/editor/DiffViewProvider"
export class VscodeDiffViewProvider extends DiffViewProvider {
override async openDiffEditor(): Promise<void> {
@@ -4,8 +4,8 @@ import { sendThemeEvent } from "@core/controller/ui/subscribeToTheme"
import { getTheme } from "@integrations/theme/getTheme"
import * as vscode from "vscode"
import { Uri } from "vscode"
import { WebviewProvider } from "."
import { sendDidBecomeVisibleEvent } from "../controller/ui/subscribeToDidBecomeVisible"
import { WebviewProvider } from "@core/webview"
import { sendDidBecomeVisibleEvent } from "@core/controller/ui/subscribeToDidBecomeVisible"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -1,5 +1,5 @@
import { StreamingCallbacks } from "@/hosts/host-provider-types"
import { HostServiceHandlerConfig, hostServiceHandlers } from "./host-grpc-service-config"
import { HostServiceHandlerConfig, hostServiceHandlers } from "@generated/hosts/vscode/hostbridge-grpc-service-config"
import { GrpcRequestRegistry } from "@core/controller/grpc-request-registry"
/**
@@ -1,4 +1,4 @@
import { StreamingResponseHandler } from "./host-grpc-handler"
import { StreamingResponseHandler } from "./hostbridge-grpc-handler"
/**
* Generic type for service method handlers
@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from "uuid"
import { GrpcHandler } from "../host-grpc-handler"
import { GrpcHandler } from "@/hosts/vscode/hostbridge-grpc-handler"
import { StreamingCallbacks } from "@/hosts/host-provider-types"
// Generic type for any protobuf service definition
@@ -1,4 +1,4 @@
import { createGrpcClient } from "@hosts/vscode/client/host-grpc-client-base"
import { createGrpcClient } from "@hosts/vscode/hostbridge/client/host-grpc-client-base"
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
import * as host from "@shared/proto/index.host"
@@ -0,0 +1,5 @@
import { ReplaceTextRequest, ReplaceTextResponse } from "@/shared/proto/index.host"
export async function replaceText(_request: ReplaceTextRequest): Promise<ReplaceTextResponse> {
throw new Error("diffService.replaceText is not supported. Use the VscodeDiffViewProvider.")
}
@@ -1,7 +1,7 @@
import * as fs from "fs/promises"
import * as fsSync from "fs"
import { SubscribeToFileRequest, FileChangeEvent, FileChangeEvent_ChangeType } from "@shared/proto/host/watch"
import { StreamingResponseHandler, getRequestRegistry } from "../host-grpc-handler"
import { SubscribeToFileRequest, FileChangeEvent_ChangeType } from "@shared/proto/host/watch"
import { StreamingResponseHandler, getRequestRegistry } from "@/hosts/vscode/hostbridge-grpc-handler"
// Debounce configuration
const DEBOUNCE_DELAY = 100 // ms
@@ -1,22 +1,24 @@
import { window } from "vscode"
import { SelectedResponse, ShowMessageRequest, ShowMessageType } from "@/shared/proto/index.host"
const DEFAULT_OPTIONS = { modal: false, items: [] } as const
export async function showMessage(request: ShowMessageRequest): Promise<SelectedResponse | undefined> {
const { message, type, options } = request
const { modal, detail, items } = options || {}
const option = items ? { modal, items } : { modal, detail }
const { modal, detail, items } = { ...DEFAULT_OPTIONS, ...options }
const option = { modal, detail }
let selectedOption: string | undefined = undefined
switch (type) {
case ShowMessageType.ERROR:
selectedOption = await window.showErrorMessage(message, option)
selectedOption = await window.showErrorMessage(message, option, ...items)
break
case ShowMessageType.WARNING:
selectedOption = await window.showWarningMessage(message, option)
selectedOption = await window.showWarningMessage(message, option, ...items)
break
default:
selectedOption = await window.showInformationMessage(message, option)
selectedOption = await window.showInformationMessage(message, option, ...items)
break
}
+8 -13
View File
@@ -54,19 +54,14 @@ export async function openFile(absolutePath: string) {
}
} catch {} // not essential, sometimes tab operations fail
const document = await vscode.workspace.openTextDocument(uri)
await getHostBridgeProvider().windowClient.showTextDocument(
ShowTextDocumentRequest.create({
path: document.uri.fsPath,
options: ShowTextDocumentOptions.create({ preview: false }),
}),
)
await getHostBridgeProvider().windowClient.showTextDocument({
path: uri.fsPath,
options: { preview: false },
})
} catch (error) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Could not open file!`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Could not open file!`,
})
}
}
+1 -37
View File
@@ -13,7 +13,7 @@ export class ClineAccountService {
private static instance: ClineAccountService
private _authService: AuthService
// TODO: replace this with a global API Host
private readonly _baseUrl = "https://api.cline.bot"
private readonly _baseUrl = "http://localhost:7777"
// private readonly _baseUrl = "https://core-api.staging.int.cline.bot"
// private readonly _baseUrl = "http://localhost:7777"
@@ -82,42 +82,6 @@ export class ClineAccountService {
}
}
/**
* Validates if the user has sufficient credits to make API requests.
* This checks the user's balance and throws an error if the balance is insufficient or if the request fails.
* @throws Error if the user has insufficient credits or if the request fails
* @returns {Promise<void>} A promise that resolves if the user has sufficient credits.
*/
async validateRequest(): Promise<void> {
try {
const { organizations, id } = await this.authenticatedRequest<UserResponse>(`/api/v1/users/me`)
const activeOrganization = organizations.find((org) => org.active)
console.log("SwitchAuthToken: Active Organization", activeOrganization?.name || "No active organization")
// Skip balance check for active organizations
if (activeOrganization) {
return
}
const balance = await this.authenticatedRequest<BalanceResponse>(`/api/v1/users/${id}/balance`)
const currentBalance = Number(balance?.balance) || 0
// Throw error if insufficient credits (balance <= 0)
if (currentBalance <= 0) {
throw new Error(
JSON.stringify({
code: "insufficient_credits",
current_balance: currentBalance,
message: "Not enough credits available",
}),
)
}
} catch (error) {
console.error("Invalid Cline API request:", error)
throw error instanceof Error ? error : new Error(`Invalid Request: ${error}`)
}
}
/**
* RPC variant that fetches the user's current credit balance without posting to webview
* @returns Balance data or undefined if failed
+18 -24
View File
@@ -1,5 +1,4 @@
import vscode from "vscode"
import crypto from "crypto"
import { EmptyRequest, String } from "../../shared/proto/common"
import { AuthState, UserInfo } from "../../shared/proto/account"
import { StreamingResponseHandler, getRequestRegistry } from "@/core/controller/grpc-handler"
@@ -7,7 +6,7 @@ import { FirebaseAuthProvider } from "./providers/FirebaseAuthProvider"
import { Controller } from "@/core/controller"
import { storeSecret } from "@/core/storage/state"
const DefaultClineAccountURI = "https://app.cline.bot/auth"
const DefaultClineAccountURI = "http://localhost:3000/auth"
// const DefaultClineAccountURI = "https://staging-app.cline.bot/auth"
// const DefaultClineAccountURI = "http://localhost:3000/auth"
let authProviders: any[] = []
@@ -51,7 +50,6 @@ export class AuthService {
private _authenticated: boolean = false
private _clineAuthInfo: ClineAuthInfo | null = null
private _provider: { provider: FirebaseAuthProvider } | null = null
private readonly _authNonce = crypto.randomBytes(32).toString("hex")
private _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler]>()
private _context: vscode.ExtensionContext
@@ -72,14 +70,14 @@ export class AuthService {
const authProvidersConfigs = [
{
name: "firebase",
config: {
apiKey: "AIzaSyC5rx59Xt8UgwdU3PCfzUF7vCwmp9-K2vk",
authDomain: "cline-prod.firebaseapp.com",
projectId: "cline-prod",
storageBucket: "cline-prod.firebasestorage.app",
messagingSenderId: "941048379330",
appId: "1:941048379330:web:45058eedeefc5cdfcc485b",
},
// config: {
// apiKey: "AIzaSyC5rx59Xt8UgwdU3PCfzUF7vCwmp9-K2vk",
// authDomain: "cline-prod.firebaseapp.com",
// projectId: "cline-prod",
// storageBucket: "cline-prod.firebasestorage.app",
// messagingSenderId: "941048379330",
// appId: "1:941048379330:web:45058eedeefc5cdfcc485b",
// },
// Uncomment for staging environment
// config: {
// apiKey: "AIzaSyASSwkwX1kSO8vddjZkE5N19QU9cVQ0CIk",
@@ -90,14 +88,15 @@ export class AuthService {
// appId: "1:853479478430:web:2de0dba1c63c3262d4578f",
// },
// Uncomment for local development environment
// config: {
// apiKey: "AIzaSyASSwkwX1kSO8vddjZkE5N19QU9cVQ0CIk",
// authDomain: "cline-staging.firebaseapp.com",
// projectId: "cline-staging",
// storageBucket: "cline-staging.firebasestorage.app",
// messagingSenderId: "853479478430",
// appId: "1:853479478430:web:2de0dba1c63c3262d4578f",
// },
config: {
apiKey: "AIzaSyD8wtkd1I-EICuAg6xgAQpRdwYTvwxZG2w",
authDomain: "cline-preview.firebaseapp.com",
projectId: "cline-preview",
storageBucket: "cline-preview.firebasestorage.app",
messagingSenderId: "654681443338",
appId: "1:654681443338:web:93bfe710626a573d9123f3",
measurementId: "G-QL8CKK5TNJ",
},
// config: {
// apiKey: "AIzaSyD8wtkd1I-EICuAg6xgAQpRdwYTvwxZG2w",
// authDomain: "cline-preview.firebaseapp.com",
@@ -158,10 +157,6 @@ export class AuthService {
this._setProvider(providerName)
}
get authNonce(): string {
return this._authNonce
}
async getAuthToken(): Promise<string | null> {
if (!this._clineAuthInfo) {
return null
@@ -220,7 +215,6 @@ export class AuthService {
// Use URL object for more graceful query construction
const authUrl = new URL(this._config.URI)
authUrl.searchParams.set("state", this._authNonce)
authUrl.searchParams.set("callback_url", callbackUrl)
const authUrlString = authUrl.toString()
+188 -296
View File
@@ -12,16 +12,6 @@ import { posthogClientProvider } from "../PostHogClientProvider"
* Respects user privacy settings and VSCode's global telemetry configuration
*/
interface CollectedTasks {
taskId: string
collection: Collection[]
}
interface Collection {
event: string
properties: any
}
/**
* Represents telemetry event categories that can be individually enabled or disabled
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
@@ -29,6 +19,11 @@ interface Collection {
*/
type TelemetryCategory = "checkpoints" | "browser"
/**
* Maximum length for error messages to prevent excessive data
*/
const MAX_ERROR_MESSAGE_LENGTH = 500
class TelemetryService {
// Map to control specific telemetry categories (event types)
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
@@ -36,8 +31,6 @@ class TelemetryService {
["browser", true], // Browser telemetry enabled
])
// Stores events when collect=true
private collectedTasks: CollectedTasks[] = []
// Event constants for tracking user interactions and system events
private static readonly EVENTS = {
// Task-related events for tracking conversation and execution flow
@@ -83,8 +76,8 @@ class TelemetryService {
BROWSER_ERROR: "task.browser_error",
// Tracks Gemini API specific performance metrics
GEMINI_API_PERFORMANCE: "task.gemini_api_performance",
// Collection of all task events
TASK_COLLECTION: "task.collection",
// Tracks when API providers return errors
PROVIDER_API_ERROR: "task.provider_api_error",
},
// UI interaction events for tracking user engagement
UI: {
@@ -190,15 +183,13 @@ class TelemetryService {
}
/**
* Captures a telemetry event if telemetry is enabled or collects if collect=true
* Captures a telemetry event if telemetry is enabled
* @param event The event to capture with its properties
* @param collect If true, store the event in collectedEvents instead of sending to PostHog
*/
public capture(event: { event: string; properties?: any }, collect: boolean = false): void {
public capture(event: { event: string; properties?: any }): void {
if (!this.telemetryEnabled) {
return
}
const taskId = event.properties.taskId
const propertiesWithVersion = this.addProperties(event.properties)
@@ -207,19 +198,7 @@ class TelemetryService {
properties: propertiesWithVersion,
}
if (collect && taskId) {
const existingTask = this.collectedTasks.find((task) => task.taskId === taskId)
if (existingTask) {
existingTask.collection.push(capturedEvent)
} else {
this.collectedTasks.push({
taskId,
collection: [capturedEvent],
})
}
} else {
this.client.capture({ ...capturedEvent, distinctId: this.distinctId })
}
this.client.capture({ ...capturedEvent, distinctId: this.distinctId })
}
public captureExtensionActivated(installId: string) {
@@ -236,47 +215,35 @@ class TelemetryService {
* Records when a new task/conversation is started
* @param taskId Unique identifier for the new task
* @param apiProvider Optional API provider
* @param collect If true, collect event instead of sending
*/
public captureTaskCreated(taskId: string, apiProvider?: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.CREATED,
properties: { taskId, apiProvider },
},
collect,
)
public captureTaskCreated(taskId: string, apiProvider?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.CREATED,
properties: { taskId, apiProvider },
})
}
/**
* Records when a task/conversation is restarted
* @param taskId Unique identifier for the new task
* @param apiProvider Optional API provider
* @param collect If true, collect event instead of sending
*/
public captureTaskRestarted(taskId: string, apiProvider?: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.RESTARTED,
properties: { taskId, apiProvider },
},
collect,
)
public captureTaskRestarted(taskId: string, apiProvider?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.RESTARTED,
properties: { taskId, apiProvider },
})
}
/**
* Records when cline calls the task completion_result tool signifying that cline is done with the task
* @param taskId Unique identifier for the task
* @param collect If true, collect event instead of sending
*/
public captureTaskCompleted(taskId: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.COMPLETED,
properties: { taskId },
},
collect,
)
public captureTaskCompleted(taskId: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.COMPLETED,
properties: { taskId },
})
}
/**
@@ -285,7 +252,6 @@ class TelemetryService {
* @param provider The API provider (e.g., OpenAI, Anthropic)
* @param model The specific model used (e.g., GPT-4, Claude)
* @param source The source of the message ("user" | "model"). Used to track message patterns and identify when users need to correct the model's responses.
* @param collect If true, collect event instead of sending
* @param tokenUsage Optional token usage data
*/
public captureConversationTurnEvent(
@@ -293,7 +259,6 @@ class TelemetryService {
provider: string = "unknown",
model: string = "unknown",
source: "user" | "assistant",
collect: boolean = false,
tokenUsage: {
tokensIn?: number
tokensOut?: number
@@ -317,13 +282,10 @@ class TelemetryService {
...tokenUsage,
}
this.capture(
{
event: TelemetryService.EVENTS.TASK.CONVERSATION_TURN,
properties,
},
collect,
)
this.capture({
event: TelemetryService.EVENTS.TASK.CONVERSATION_TURN,
properties,
})
}
/**
@@ -333,19 +295,16 @@ class TelemetryService {
* @param tokensOut Number of output tokens generated
* @param model The model used for token calculation
*/
public captureTokenUsage(taskId: string, tokensIn: number, tokensOut: number, model: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.TOKEN_USAGE,
properties: {
taskId,
tokensIn,
tokensOut,
model,
},
public captureTokenUsage(taskId: string, tokensIn: number, tokensOut: number, model: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.TOKEN_USAGE,
properties: {
taskId,
tokensIn,
tokensOut,
model,
},
collect,
)
})
}
/**
@@ -353,17 +312,14 @@ class TelemetryService {
* @param taskId Unique identifier for the task
* @param mode The mode being switched to (plan or act)
*/
public captureModeSwitch(taskId: string, mode: "plan" | "act", collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.MODE_SWITCH,
properties: {
taskId,
mode,
},
public captureModeSwitch(taskId: string, mode: "plan" | "act") {
this.capture({
event: TelemetryService.EVENTS.TASK.MODE_SWITCH,
properties: {
taskId,
mode,
},
collect,
)
})
}
/**
@@ -371,18 +327,15 @@ class TelemetryService {
* @param taskId Unique identifier for the task
* @param feedbackType The type of feedback ("thumbs_up" or "thumbs_down")
*/
public captureTaskFeedback(taskId: string, feedbackType: TaskFeedbackType, collect: boolean = false) {
public captureTaskFeedback(taskId: string, feedbackType: TaskFeedbackType) {
console.info("TelemetryService: Capturing task feedback", { taskId, feedbackType })
this.capture(
{
event: TelemetryService.EVENTS.TASK.FEEDBACK,
properties: {
taskId,
feedbackType,
},
this.capture({
event: TelemetryService.EVENTS.TASK.FEEDBACK,
properties: {
taskId,
feedbackType,
},
collect,
)
})
}
// Tool events
@@ -393,27 +346,17 @@ class TelemetryService {
* @param autoApproved Whether the tool was auto-approved based on settings
* @param success Whether the tool execution was successful
*/
public captureToolUsage(
taskId: string,
tool: string,
modelId: string,
autoApproved: boolean,
success: boolean,
collect: boolean = false,
) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.TOOL_USED,
properties: {
taskId,
tool,
autoApproved,
success,
modelId,
},
public captureToolUsage(taskId: string, tool: string, modelId: string, autoApproved: boolean, success: boolean) {
this.capture({
event: TelemetryService.EVENTS.TASK.TOOL_USED,
properties: {
taskId,
tool,
autoApproved,
success,
modelId,
},
collect,
)
})
}
/**
@@ -426,23 +369,19 @@ class TelemetryService {
taskId: string,
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated",
durationMs?: number,
collect: boolean = false,
) {
if (!this.isCategoryEnabled("checkpoints")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.TASK.CHECKPOINT_USED,
properties: {
taskId,
action,
durationMs,
},
this.capture({
event: TelemetryService.EVENTS.TASK.CHECKPOINT_USED,
properties: {
taskId,
action,
durationMs,
},
collect,
)
})
}
/**
@@ -450,18 +389,15 @@ class TelemetryService {
* @param taskId Unique identifier for the task
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
*/
public captureDiffEditFailure(taskId: string, modelId: string, errorType?: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.DIFF_EDIT_FAILED,
properties: {
taskId,
errorType,
modelId,
},
public captureDiffEditFailure(taskId: string, modelId: string, errorType?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.DIFF_EDIT_FAILED,
properties: {
taskId,
errorType,
modelId,
},
collect,
)
})
}
/**
@@ -470,50 +406,41 @@ class TelemetryService {
* @param provider Provider of the selected model
* @param taskId Optional task identifier if model was selected during a task
*/
public captureModelSelected(model: string, provider: string, taskId?: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.UI.MODEL_SELECTED,
properties: {
model,
provider,
taskId,
},
public captureModelSelected(model: string, provider: string, taskId?: string) {
this.capture({
event: TelemetryService.EVENTS.UI.MODEL_SELECTED,
properties: {
model,
provider,
taskId,
},
collect,
)
})
}
/**
* Records when a historical task is loaded from storage
* @param taskId Unique identifier for the historical task
*/
public captureHistoricalTaskLoaded(taskId: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.HISTORICAL_LOADED,
properties: {
taskId,
},
public captureHistoricalTaskLoaded(taskId: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.HISTORICAL_LOADED,
properties: {
taskId,
},
collect,
)
})
}
/**
* Records when the retry button is clicked for failed operations
* @param taskId Unique identifier for the task being retried
*/
public captureRetryClicked(taskId: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.RETRY_CLICKED,
properties: {
taskId,
},
public captureRetryClicked(taskId: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.RETRY_CLICKED,
properties: {
taskId,
},
collect,
)
})
}
/**
@@ -521,24 +448,21 @@ class TelemetryService {
* @param taskId Unique identifier for the task
* @param browserSettings The browser settings being used
*/
public captureBrowserToolStart(taskId: string, browserSettings: BrowserSettings, collect: boolean = false) {
public captureBrowserToolStart(taskId: string, browserSettings: BrowserSettings) {
if (!this.isCategoryEnabled("browser")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.TASK.BROWSER_TOOL_START,
properties: {
taskId,
viewport: browserSettings.viewport,
isRemote: !!browserSettings.remoteBrowserEnabled,
remoteBrowserHost: browserSettings.remoteBrowserHost,
timestamp: new Date().toISOString(),
},
this.capture({
event: TelemetryService.EVENTS.TASK.BROWSER_TOOL_START,
properties: {
taskId,
viewport: browserSettings.viewport,
isRemote: !!browserSettings.remoteBrowserEnabled,
remoteBrowserHost: browserSettings.remoteBrowserHost,
timestamp: new Date().toISOString(),
},
collect,
)
})
}
/**
@@ -553,25 +477,21 @@ class TelemetryService {
duration: number
actions?: string[]
},
collect: boolean = false,
) {
if (!this.isCategoryEnabled("browser")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.TASK.BROWSER_TOOL_END,
properties: {
taskId,
actionCount: stats.actionCount,
duration: stats.duration,
actions: stats.actions,
timestamp: new Date().toISOString(),
},
this.capture({
event: TelemetryService.EVENTS.TASK.BROWSER_TOOL_END,
properties: {
taskId,
actionCount: stats.actionCount,
duration: stats.duration,
actions: stats.actions,
timestamp: new Date().toISOString(),
},
collect,
)
})
}
/**
@@ -591,25 +511,21 @@ class TelemetryService {
isRemote?: boolean
[key: string]: any
},
collect: boolean = false,
) {
if (!this.isCategoryEnabled("browser")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.TASK.BROWSER_ERROR,
properties: {
taskId,
errorType,
errorMessage,
context,
timestamp: new Date().toISOString(),
},
this.capture({
event: TelemetryService.EVENTS.TASK.BROWSER_ERROR,
properties: {
taskId,
errorType,
errorMessage,
context,
timestamp: new Date().toISOString(),
},
collect,
)
})
}
/**
@@ -618,18 +534,15 @@ class TelemetryService {
* @param qty The quantity of options that were presented
* @param mode The mode in which the option was selected ("plan" or "act")
*/
public captureOptionSelected(taskId: string, qty: number, mode: "plan" | "act", collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.OPTION_SELECTED,
properties: {
taskId,
qty,
mode,
},
public captureOptionSelected(taskId: string, qty: number, mode: "plan" | "act") {
this.capture({
event: TelemetryService.EVENTS.TASK.OPTION_SELECTED,
properties: {
taskId,
qty,
mode,
},
collect,
)
})
}
/**
@@ -638,18 +551,15 @@ class TelemetryService {
* @param qty The quantity of options that were presented
* @param mode The mode in which the custom response was provided ("plan" or "act")
*/
public captureOptionsIgnored(taskId: string, qty: number, mode: "plan" | "act", collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.OPTIONS_IGNORED,
properties: {
taskId,
qty,
mode,
},
public captureOptionsIgnored(taskId: string, qty: number, mode: "plan" | "act") {
this.capture({
event: TelemetryService.EVENTS.TASK.OPTIONS_IGNORED,
properties: {
taskId,
qty,
mode,
},
collect,
)
})
}
/**
@@ -657,7 +567,6 @@ class TelemetryService {
* @param taskId Unique identifier for the task
* @param modelId Specific Gemini model ID
* @param data Performance data including TTFT, durations, token counts, cache stats, and API success status
* @param collect If true, collect event instead of sending
*/
public captureGeminiApiPerformance(
taskId: string,
@@ -674,19 +583,15 @@ class TelemetryService {
apiError?: string
throughputTokensPerSec?: number
},
collect: boolean = false,
) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.GEMINI_API_PERFORMANCE,
properties: {
taskId,
modelId,
...data,
},
this.capture({
event: TelemetryService.EVENTS.TASK.GEMINI_API_PERFORMANCE,
properties: {
taskId,
modelId,
...data,
},
collect,
)
})
}
/**
@@ -694,30 +599,50 @@ class TelemetryService {
* @param model The name of the model the user has interacted with
* @param isFavorited Whether the model is being favorited (true) or unfavorited (false)
*/
public captureModelFavoritesUsage(model: string, isFavorited: boolean, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.UI.MODEL_FAVORITE_TOGGLED,
properties: {
model,
isFavorited,
},
public captureModelFavoritesUsage(model: string, isFavorited: boolean) {
this.capture({
event: TelemetryService.EVENTS.UI.MODEL_FAVORITE_TOGGLED,
properties: {
model,
isFavorited,
},
collect,
)
})
}
public captureButtonClick(button: string, taskId?: string, collect: boolean = false) {
this.capture(
{
event: TelemetryService.EVENTS.UI.BUTTON_CLICKED,
properties: {
button,
taskId,
},
public captureButtonClick(button: string, taskId?: string) {
this.capture({
event: TelemetryService.EVENTS.UI.BUTTON_CLICKED,
properties: {
button,
taskId,
},
collect,
)
})
}
/**
* Records telemetry when an API provider returns an error
* @param taskId Unique identifier for the task
* @param model Identifier of the model used
* @param requestId Unique identifier for the specific API request
* @param errorMessage Detailed error message from the API provider
* @param errorStatus HTTP status code of the error response, if available
* @param collect Optional flag to determine if the event should be collected for batch sending
*/
public captureProviderApiError(args: {
taskId: string
model: string
errorMessage: string
errorStatus?: number | undefined
requestId?: string | undefined
}) {
this.capture({
event: TelemetryService.EVENTS.TASK.PROVIDER_API_ERROR,
properties: {
...args,
errorMessage: args.errorMessage.substring(0, MAX_ERROR_MESSAGE_LENGTH), // Truncate long error messages
timestamp: new Date().toISOString(),
},
})
}
/**
@@ -738,39 +663,6 @@ class TelemetryService {
return this.telemetryCategoryEnabled.get(category) ?? true
}
public async sendCollectedEvents(taskId?: string): Promise<void> {
if (!this.telemetryEnabled) {
return
}
if (this.collectedTasks.length > 0) {
if (taskId) {
const task = this.collectedTasks.find((t) => t.taskId === taskId)
if (task) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.TASK_COLLECTION,
properties: { taskId, events: task.collection },
},
false,
)
this.collectedTasks = this.collectedTasks.filter((t) => t.taskId !== taskId)
}
} else {
for (const task of this.collectedTasks) {
this.capture(
{
event: TelemetryService.EVENTS.TASK.TASK_COLLECTION,
properties: { taskId: task.taskId, events: task.collection },
},
false,
)
this.collectedTasks = this.collectedTasks.filter((t) => t.taskId !== task.taskId)
}
}
}
}
public async shutdown(): Promise<void> {
await this.client.shutdown()
}
+94 -1
View File
@@ -28,6 +28,7 @@ export type ApiProvider =
| "sambanova"
| "cerebras"
| "sapaicore"
| "groq"
export interface ApiHandlerOptions {
apiModelId?: string
@@ -99,6 +100,9 @@ export interface ApiHandlerOptions {
reasoningEffort?: string
sambanovaApiKey?: string
cerebrasApiKey?: string
groqApiKey?: string
groqModelId?: string
groqModelInfo?: ModelInfo
requestTimeoutMs?: number
sapAiCoreClientId?: string
sapAiCoreClientSecret?: string
@@ -2403,6 +2407,95 @@ export const cerebrasModels = {
},
} as const satisfies Record<string, ModelInfo>
// Groq
// https://console.groq.com/docs/models
// https://groq.com/pricing/
export type GroqModelId = keyof typeof groqModels
export const groqDefaultModelId: GroqModelId = "moonshotai/kimi-k2-instruct"
export const groqModels = {
// Compound Beta Models - Hybrid architectures optimized for tool use
"compound-beta": {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.0,
outputPrice: 0.0,
description:
"Compound model using Llama 4 Scout for core reasoning with Llama 3.3 70B for routing and tool use. Excellent for plan/act workflows.",
},
"compound-beta-mini": {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.0,
outputPrice: 0.0,
description: "Lightweight compound model for faster inference while maintaining tool use capabilities.",
},
// DeepSeek Models - Reasoning-optimized
"deepseek-r1-distill-llama-70b": {
maxTokens: 131072,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.75,
outputPrice: 0.99,
description:
"DeepSeek R1 reasoning capabilities distilled into Llama 70B architecture. Excellent for complex problem-solving and planning.",
},
// Llama 4 Models
"meta-llama/llama-4-maverick-17b-128e-instruct": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.2,
outputPrice: 0.6,
description: "Meta's Llama 4 Maverick 17B model with 128 experts, supports vision and multimodal tasks.",
},
"meta-llama/llama-4-scout-17b-16e-instruct": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.11,
outputPrice: 0.34,
description: "Meta's Llama 4 Scout 17B model with 16 experts, optimized for fast inference and general tasks.",
},
// Llama 3.3 Models
"llama-3.3-70b-versatile": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.59,
outputPrice: 0.79,
description: "Meta's latest Llama 3.3 70B model optimized for versatile use cases with excellent performance and speed.",
},
// Llama 3.1 Models - Fast inference
"llama-3.1-8b-instant": {
maxTokens: 131072,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.05,
outputPrice: 0.08,
description: "Fast and efficient Llama 3.1 8B model optimized for speed, low latency, and reliable tool execution.",
},
// Mistral Models
"moonshotai/kimi-k2-instruct": {
maxTokens: 16384,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.0,
outputPrice: 3.0,
description:
"Kimi K2 is Moonshot AI's state-of-the-art Mixture-of-Experts (MoE) language model with 1 trillion total parameters and 32 billion activated parameters.",
},
} as const satisfies Record<string, ModelInfo>
// Requesty
// https://requesty.ai/models
export const requestyDefaultModelId = "anthropic/claude-3-7-sonnet-latest"
@@ -2570,7 +2663,7 @@ export const moonshotModels = {
"moonshot-v1-128k-vision-preview": {
maxTokens: 131_072,
contextWindow: 131_072,
supportsImages: false,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 2,
outputPrice: 5,
@@ -236,6 +236,8 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
return ProtoApiProvider.SAMBANOVA
case "cerebras":
return ProtoApiProvider.CEREBRAS
case "groq":
return ProtoApiProvider.GROQ
case "sapaicore":
return ProtoApiProvider.SAPAICORE
case "claude-code":
@@ -298,6 +300,8 @@ function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvider {
return "sambanova"
case ProtoApiProvider.CEREBRAS:
return "cerebras"
case ProtoApiProvider.GROQ:
return "groq"
case ProtoApiProvider.SAPAICORE:
return "sapaicore"
case ProtoApiProvider.CLAUDE_CODE:
@@ -378,6 +382,9 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
reasoningEffort: config.reasoningEffort,
sambanovaApiKey: config.sambanovaApiKey,
cerebrasApiKey: config.cerebrasApiKey,
groqApiKey: config.groqApiKey,
groqModelId: config.groqModelId,
groqModelInfo: convertModelInfoToProtoOpenRouter(config.groqModelInfo),
requestTimeoutMs: config.requestTimeoutMs,
apiProvider: config.apiProvider ? convertApiProviderToProto(config.apiProvider) : undefined,
favoritedModelIds: config.favoritedModelIds || [],
@@ -461,6 +468,9 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
reasoningEffort: protoConfig.reasoningEffort,
sambanovaApiKey: protoConfig.sambanovaApiKey,
cerebrasApiKey: protoConfig.cerebrasApiKey,
groqApiKey: protoConfig.groqApiKey,
groqModelId: protoConfig.groqModelId,
groqModelInfo: convertProtoToModelInfo(protoConfig.groqModelInfo),
requestTimeoutMs: protoConfig.requestTimeoutMs,
apiProvider: protoConfig.apiProvider !== undefined ? convertProtoToApiProvider(protoConfig.apiProvider) : undefined,
favoritedModelIds: protoConfig.favoritedModelIds.length > 0 ? protoConfig.favoritedModelIds : undefined,
+10 -1
View File
@@ -1,6 +1,15 @@
// Public PostHog key (safe for open source)
export const posthogConfig = {
const posthogProdConfig = {
apiKey: "phc_qfOAGxZw2TL5O8p9KYd9ak3bPBFzfjC8fy5L6jNWY7K",
host: "https://data.cline.bot",
uiHost: "https://us.posthog.com",
}
// Public PostHog key for Development Environment project
const posthogDevEnvConfig = {
apiKey: "phc_uY24EJXNBcc9kwO1K8TJUl5hPQntGM6LL1Mtrz0CBD4",
host: "https://data.cline.bot",
uiHost: "https://us.i.posthog.com",
}
export const posthogConfig = process.env.IS_DEV === "true" ? posthogDevEnvConfig : posthogProdConfig
+14 -4
View File
@@ -2,17 +2,27 @@ import { getHostBridgeProvider } from "@/hosts/host-providers"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
export class ExternalDiffViewProvider extends DiffViewProvider {
private activeDiffEditorId: string | undefined
override async openDiffEditor(): Promise<void> {
if (!this.absolutePath) {
return
}
getHostBridgeProvider().diffClient.openDiff({ path: this.absolutePath, content: this.originalContent ?? "" })
const response = await getHostBridgeProvider().diffClient.openDiff({
path: this.absolutePath,
content: this.originalContent ?? "",
})
this.activeDiffEditorId = response.diffId
}
override replaceText(
override async replaceText(
content: string,
rangeToReplace: { startLine: number; endLine: number },
currentLine: number,
_currentLine: number,
): Promise<void> {
throw new Error("Method not implemented.")
await getHostBridgeProvider().diffClient.replaceText({
diffId: this.activeDiffEditorId,
content: content,
startLine: rangeToReplace.startLine,
endLine: rangeToReplace.endLine,
})
}
}
+4 -1
View File
@@ -15,6 +15,9 @@ import { WebviewProviderType } from "@/shared/webview/types"
import { v4 as uuidv4 } from "uuid"
import { ExternalDiffViewProvider } from "./ExternalDiffviewProvider"
export const PROTOBUS_PORT = 26040
export const HOSTBRIDGE_PORT = 26041
async function main() {
log("Starting standalone service...")
@@ -42,7 +45,7 @@ function startProtobusService(controller: Controller) {
reflection.addToServer(server)
// Start the server.
const host = process.env.PROTOBUS_ADDRESS || "127.0.0.1:50051"
const host = process.env.PROTOBUS_ADDRESS || `127.0.0.1:${PROTOBUS_PORT}`
server.bindAsync(host, grpc.ServerCredentials.createInsecure(), (err) => {
if (err) {
log(`Error: Failed to bind to ${host}, port may be unavailable. ${err.message}`)
+2 -1
View File
@@ -14,6 +14,7 @@ import {
DiffServiceClientInterface,
} from "@generated/hosts/host-bridge-client-types"
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
import { HOSTBRIDGE_PORT } from "./cline-core"
/**
* Manager to hold the gRPC clients for the host bridge. The clients should be re-used to avoid
@@ -28,7 +29,7 @@ export class ExternalHostBridgeClientManager implements HostBridgeClientProvider
diffClient: DiffServiceClientInterface
constructor() {
const address = process.env.HOST_BRIDGE_ADDRESS || "localhost:50052"
const address = process.env.HOST_BRIDGE_ADDRESS || `localhost:${HOSTBRIDGE_PORT}`
this.channel = createChannel(address)
this.watchServiceClient = new WatchServiceClientImpl(this.channel)
+29
View File
@@ -0,0 +1,29 @@
# E2E Tests
This directory contains the end-to-end tests for the extension using Playwright. These tests simulate user interactions with the extension in a real VS Code environment.
## Running Tests
To build the test environment and run all E2E tests:
```bash
npm run test:e2e
```
To run all E2E tests without re-building the test environment (e.g. only test files were updated):
```bash
npm run e2e
```
To run E2E tests in debug mode:
```bash
npm run test:e2e -- --debug
# Or only run the tests without re-building
npm run e2e -- --debug
```
## Writing Tests
TBC
+57
View File
@@ -0,0 +1,57 @@
import { expect } from "@playwright/test"
import { e2e } from "./utils/helpers"
e2e("Auth - can set up API keys", async ({ page, sidebar }) => {
// Verify initial state
await expect(sidebar.getByRole("button", { name: "Get Started for Free" })).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Use your own API key" })).toBeVisible()
// Navigate to API key setup
await sidebar.getByRole("button", { name: "Use your own API key" }).click()
const providerSelector = sidebar.locator("#api-provider div").first()
// Verify provider selector is visible and set to OpenRouter
await expect(sidebar.locator("slot").filter({ hasText: /^OpenRouter$/ })).toBeVisible()
// Test Cline provider option
await providerSelector.click({ delay: 100 })
await expect(sidebar.getByRole("option", { name: "Cline" })).toBeVisible()
await sidebar.getByRole("option", { name: "Cline" }).click({ delay: 100 })
await expect(sidebar.getByRole("button", { name: "Sign Up with Cline" })).toBeVisible()
// Switch to OpenRouter and complete setup
await providerSelector.click({ delay: 100 })
await sidebar.getByRole("option", { name: "OpenRouter" }).click({ delay: 100 })
const apiKeyInput = sidebar.getByRole("textbox", { name: "OpenRouter API Key" })
await apiKeyInput.fill("test-api-key")
await expect(apiKeyInput).toHaveValue("test-api-key")
await apiKeyInput.click({ delay: 100 })
const submitButton = sidebar.getByRole("button", { name: "Let's go!" })
await expect(submitButton).toBeEnabled()
await submitButton.click({ delay: 100 })
await expect(sidebar.getByRole("button", { name: "Get Started for Free" })).not.toBeVisible()
// Verify start up page is no longer visible
await expect(apiKeyInput).not.toBeVisible()
await expect(providerSelector).not.toBeVisible()
// Verify you are now in the chat page after setup was completed
const clineLogo = sidebar.getByRole("img").filter({ hasText: /^$/ }).locator("path")
await expect(clineLogo).toBeVisible()
const chatInputBox = sidebar.getByTestId("chat-input")
await expect(chatInputBox).toBeVisible()
// Verify the help improve banner is visible and can be closed.
const helpBanner = sidebar.getByText("Help Improve Cline")
await expect(helpBanner).toBeVisible()
await sidebar.getByRole("button", { name: "Close banner and enable" }).click()
await expect(helpBanner).not.toBeVisible()
// Verify the release banner is visible for new installs and can be closed.
const releaseBanner = sidebar.getByRole("heading", { name: /^🎉 New in v\d/ })
await expect(releaseBanner).toBeVisible()
await sidebar.getByTestId("close-button").locator("span").first().click()
await expect(releaseBanner).not.toBeVisible()
})
+49
View File
@@ -0,0 +1,49 @@
import { expect } from "@playwright/test"
import { e2e, signin } from "./utils/helpers"
e2e("Chat - can send messages and switch between modes", async ({ page, sidebar }) => {
// Sign in
await signin(sidebar)
// Submit a message
const inputbox = sidebar.getByTestId("chat-input")
await expect(inputbox).toBeVisible()
await inputbox.fill("Hello, Cline!")
await expect(inputbox).toHaveValue("Hello, Cline!")
await sidebar.getByTestId("send-button").click({ delay: 100 })
await expect(inputbox).toHaveValue("")
// Loading State initially
await expect(sidebar.getByText("API Request...")).toBeVisible()
// The request should eventually fail
await expect(sidebar.getByText("API Request Failed")).toBeVisible()
await expect(inputbox).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Retry" })).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Start New Task" })).toBeVisible()
// Starting a new task should clear the current chat view and show the recent tasks
await sidebar.getByRole("button", { name: "Start New Task" }).click()
await expect(sidebar.getByText("API Request Failed")).not.toBeVisible()
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
// Makes sure the act and plan switches are working correctly
// Aria-checked state should be true for Act and false for Plan
const actButton = sidebar.getByRole("switch", { name: "Act" })
const planButton = sidebar.getByRole("switch", { name: "Plan" })
await expect(actButton).toBeChecked()
await expect(planButton).not.toBeChecked()
await actButton.click()
await expect(actButton).not.toBeChecked()
await expect(planButton).toBeChecked()
await sidebar.getByTestId("chat-input").fill("Plan mode submission")
await sidebar.getByTestId("send-button").click()
await expect(sidebar.getByText("API Request Failed")).toBeVisible()
})
@@ -0,0 +1,2 @@
node_modules
.vscode
@@ -0,0 +1,3 @@
# Test Workspace
This workspace is used for testing the extension in a controlled environment.
@@ -0,0 +1,10 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Test Workspace</title>
</head>
<body>
<h1>Test Workspace</h1>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
/**
* Script to install dependencies for running E2E tests in GitHub Actions.
*/
import { SilentReporter, downloadAndUnzipVSCode } from "@vscode/test-electron"
import { execa } from "execa"
const TIMEOUT_MINUTE = 1
const INSTALL_TIMEOUT_MS = TIMEOUT_MINUTE * 60 * 1000
async function installVSCode() {
const VSCODE_APP_TYPE = "stable"
console.log("Downloading VS Code...")
return await downloadAndUnzipVSCode(VSCODE_APP_TYPE, undefined, new SilentReporter())
}
async function installChromium() {
console.log("Installing Playwright Chromium...")
try {
await execa("npm", ["exec", "playwright", "install", "chromium"], {
stdio: "inherit",
})
console.log("Playwright Chromium installation completed successfully")
} catch (error) {
throw new Error(`Failed to install Playwright Chromium: ${error}`)
}
}
async function installDependencies() {
return Promise.all([installVSCode(), installChromium()])
}
async function main() {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Installation timed out.")), INSTALL_TIMEOUT_MS),
)
await Promise.race([installDependencies(), timeoutPromise])
console.log("Installation complete.")
process.exit(0)
}
main().catch((error) => {
console.error("Failed to install dependencies for E2E test", error)
process.exit(1)
})
+187
View File
@@ -0,0 +1,187 @@
import { type ElectronApplication, type Frame, type Page, test, expect } from "@playwright/test"
import { type PathLike, type RmOptions, mkdtempSync, rmSync } from "node:fs"
import { _electron } from "playwright"
import { SilentReporter, downloadAndUnzipVSCode } from "@vscode/test-electron"
import * as os from "node:os"
import * as path from "node:path"
interface E2ETestDirectories {
workspaceDir: string
userDataDir: string
extensionsDir: string
}
// Constants
const CODEBASE_ROOT_DIR = path.resolve(__dirname, "..", "..", "..", "..")
const E2E_TESTS_DIR = path.join(CODEBASE_ROOT_DIR, "src", "test", "e2e")
// Path utilities
const escapeToPath = (text: string): string => text.trim().toLowerCase().replaceAll(/\W/g, "_")
const getResultsDir = (testName = "", label?: string): string => {
const testDir = path.join(CODEBASE_ROOT_DIR, "test-results", "playwright", escapeToPath(testName))
return label ? path.join(testDir, label) : testDir
}
async function waitUntil(predicate: () => boolean | Promise<boolean>, maxDelay = 5000): Promise<void> {
let delay = 10
const start = Date.now()
while (!(await predicate())) {
if (Date.now() - start > maxDelay) {
throw new Error(`waitUntil timeout after ${maxDelay}ms`)
}
await new Promise((resolve) => setTimeout(resolve, delay))
delay = Math.min(delay << 1, 1000) // Cap at 1s
}
}
export async function getSidebar(page: Page): Promise<Frame> {
let cachedFrame: Frame | null = null
const findSidebarFrame = async (): Promise<Frame | null> => {
// Check cached frame first
if (cachedFrame && !cachedFrame.isDetached()) {
return cachedFrame
}
for (const frame of page.frames()) {
if (frame.isDetached()) {
continue
}
try {
const title = await frame.title()
if (title.startsWith("Cline")) {
cachedFrame = frame
return frame
}
} catch (error: any) {
if (!error.message.includes("detached") && !error.message.includes("navigation")) {
throw error
}
}
}
return null
}
await waitUntil(async () => (await findSidebarFrame()) !== null)
return (await findSidebarFrame()) || page.mainFrame()
}
export async function rmForRetries(path: PathLike, options?: RmOptions): Promise<void> {
const maxAttempts = 3 // Reduced from 5
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
rmSync(path, options)
return
} catch (error) {
if (attempt === maxAttempts) {
throw new Error(`Failed to rmSync ${path} after ${maxAttempts} attempts: ${error}`)
}
await new Promise((resolve) => setTimeout(resolve, 50 * attempt)) // Progressive delay
}
}
}
export async function signin(webview: Frame): Promise<void> {
const byokButton = webview.getByRole("button", { name: "Use your own API key" })
await expect(byokButton).toBeVisible()
await byokButton.click()
// Complete setup with OpenRouter
const apiKeyInput = webview.getByRole("textbox", { name: "OpenRouter API Key" })
await apiKeyInput.fill("test-api-key")
await webview.getByRole("button", { name: "Let's go!" }).click()
// Verify start up page is no longer visible
await expect(webview.locator("#api-provider div").first()).not.toBeVisible()
await expect(byokButton).not.toBeVisible()
}
export async function openClineSidebar(page: Page): Promise<void> {
await page.getByRole("tab", { name: /Cline/ }).locator("a").click()
}
export async function runCommandPalette(page: Page, command: string): Promise<void> {
await page.locator("li").filter({ hasText: "[Extension Development Host]" }).first().click()
const editorSearchBar = page.getByRole("textbox", { name: "Search files by name (append" })
await expect(editorSearchBar).toBeVisible()
await editorSearchBar.click()
await editorSearchBar.fill(`>${command}`)
await page.keyboard.press("Enter")
}
// Test configuration
export const e2e = test
.extend<E2ETestDirectories>({
workspaceDir: async ({}, use) => {
await use(path.join(E2E_TESTS_DIR, "fixtures", "workspace"))
},
userDataDir: async ({}, use) => {
await use(mkdtempSync(path.join(os.tmpdir(), "vsce")))
},
extensionsDir: async ({}, use) => {
await use(mkdtempSync(path.join(os.tmpdir(), "vsce")))
},
})
.extend<{ openVSCode: () => Promise<ElectronApplication> }>({
openVSCode: async ({ workspaceDir, userDataDir, extensionsDir }, use, testInfo) => {
const executablePath = await downloadAndUnzipVSCode("stable", undefined, new SilentReporter())
await use(async () => {
const app = await _electron.launch({
executablePath,
env: { ...process.env, IS_DEV: "true", TEMP_PROFILE: "true", E2E_TEST: "true" },
recordVideo: { dir: getResultsDir(testInfo.title, "recordings") },
args: [
"--no-sandbox",
"--disable-updates",
"--disable-workspace-trust",
"--skip-welcome",
"--skip-release-notes",
`--user-data-dir=${userDataDir}`,
`--extensions-dir=${extensionsDir}`,
`--install-extension=${path.join(CODEBASE_ROOT_DIR, "dist", "e2e.vsix")}`,
`--extensionDevelopmentPath=${CODEBASE_ROOT_DIR}`,
workspaceDir,
],
})
await waitUntil(() => app.windows().length > 0)
return app
})
},
})
.extend<{ app: ElectronApplication }>({
app: async ({ openVSCode, userDataDir, extensionsDir }, use) => {
const app = await openVSCode()
try {
await use(app)
} finally {
await app.close()
// Cleanup in parallel
await Promise.allSettled([
rmForRetries(userDataDir, { recursive: true }),
rmForRetries(extensionsDir, { recursive: true }),
])
}
},
})
.extend({
page: async ({ app }, use) => {
const page = await app.firstWindow()
await runCommandPalette(page, "notifications: toggle do not disturb")
await openClineSidebar(page)
await use(page)
},
})
.extend<{ sidebar: Frame }>({
sidebar: async ({ page }, use) => {
const sidebar = await getSidebar(page)
await use(sidebar)
},
})
export { getResultsDir }
+22
View File
@@ -0,0 +1,22 @@
import { rmSync } from "node:fs"
import { getResultsDir } from "./helpers"
export default async function (): Promise<void> {
const path = getResultsDir()
const options = { recursive: true, force: true }
const maxAttempts = 2
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
rmSync(path, options)
return
} catch (error) {
if (attempt === maxAttempts) {
throw new Error(`Failed to rmSync ${path} after ${maxAttempts} attempts: ${error}`)
}
console.error(`Failed to rmSync ${path} after ${attempt} attempts: ${error}`)
await new Promise((resolve) => setTimeout(resolve, 50 * attempt)) // Progressive delay
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import fs from "node:fs/promises"
import path from "node:path"
import type { FullConfig } from "playwright/test"
import { getResultsDir, rmForRetries } from "./helpers"
export default async function (_: FullConfig) {
const assetsDir = getResultsDir()
try {
const results = await fs.readdir(assetsDir, { withFileTypes: true })
await Promise.all(
results
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const dirPath = path.join(assetsDir, entry.name)
const recordingsPath = getResultsDir(entry.name, "recordings")
const recordings = await fs.readdir(recordingsPath)
// If there is only one recording, it means the test passed as no retries were needed.
if (recordings.length === 1) {
await rmForRetries(dirPath, { recursive: true, force: true })
}
}),
)
} catch (error) {
// Silently handle case where assets directory doesn't exist
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
}
+1 -1
View File
@@ -34,5 +34,5 @@
}
},
"include": ["src/**/*", "scripts/**/*"],
"exclude": ["node_modules", ".vscode-test", "webview-ui"]
"exclude": ["node_modules", ".vscode-test", "webview-ui", "src/test/e2e/**/*"]
}
+1 -1
View File
@@ -14,5 +14,5 @@
"rootDir": "."
},
"include": ["src/**/*.test.ts"],
"exclude": ["src/test/**/*.js", "src/**/__tests__/*"]
"exclude": ["src/test/**/*.js", "src/**/__tests__/*", "src/test/e2e/**/*.test.ts"]
}
+9 -1
View File
@@ -8,7 +8,15 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
const { telemetrySetting, distinctId, version } = useExtensionState()
const isTelemetryEnabled = telemetrySetting !== "disabled"
// NOTE: This is a hack to stop recording webview click events temporarily.
// Remove this to re-enable.
const temporaryDisabled = true
useEffect(() => {
if (temporaryDisabled) {
return
}
posthog.init(posthogConfig.apiKey, {
api_host: posthogConfig.host,
ui_host: posthogConfig.uiHost,
@@ -19,7 +27,7 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
}, [])
useEffect(() => {
if (distinctId.length === 0 || version.length === 0) {
if (temporaryDisabled || distinctId.length === 0 || version.length === 0) {
return
}
@@ -1,4 +1,11 @@
import { VSCodeButton, VSCodeDivider, VSCodeLink, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import {
VSCodeButton,
VSCodeDivider,
VSCodeLink,
VSCodeDropdown,
VSCodeOption,
VSCodeTag,
} from "@vscode/webview-ui-toolkit/react"
import { memo, useCallback, useEffect, useState, useRef } from "react"
import { useClineAuth } from "@/context/ClineAuthContext"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
@@ -95,6 +102,15 @@ const AccountView = ({ onDone }: AccountViewProps) => {
)
}
const getMainRole = (roles?: string[]) => {
if (!roles) return undefined
if (roles.includes("owner")) return "Owner"
if (roles.includes("admin")) return "Admin"
return "Member"
}
export const ClineAccountView = () => {
const { clineUser, handleSignIn, handleSignOut } = useClineAuth()
const { userInfo, apiConfiguration } = useExtensionState()
@@ -221,21 +237,28 @@ export const ClineAccountView = () => {
<div className="text-sm text-[var(--vscode-descriptionForeground)]">{user.email}</div>
)}
{userOrganizations && (
<VSCodeDropdown
key={activeOrganization?.organizationId || "personal"}
currentValue={activeOrganization?.organizationId || ""}
onChange={handleOrganizationChange}
disabled={isSwitchingOrg || isLoading}
style={{ width: "100%", marginTop: "4px" }}>
<VSCodeOption value="">Personal</VSCodeOption>
{userOrganizations.map((org: UserOrganization) => (
<VSCodeOption key={org.organizationId} value={org.organizationId}>
{org.name}
</VSCodeOption>
))}
</VSCodeDropdown>
)}
<div className="flex gap-2 items-center mt-1">
{userOrganizations && (
<VSCodeDropdown
key={activeOrganization?.organizationId || "personal"}
currentValue={activeOrganization?.organizationId || ""}
onChange={handleOrganizationChange}
disabled={isSwitchingOrg || isLoading}
className="w-full">
<VSCodeOption value="">Personal</VSCodeOption>
{userOrganizations.map((org: UserOrganization) => (
<VSCodeOption key={org.organizationId} value={org.organizationId}>
{org.name}
</VSCodeOption>
))}
</VSCodeDropdown>
)}
{activeOrganization?.roles && (
<VSCodeTag className="text-xs p-2" title="Role">
{getMainRole(activeOrganization.roles)}
</VSCodeTag>
)}
</div>
</div>
</div>
</div>
@@ -427,6 +427,10 @@ export const ChatRowContent = memo(
}
if (apiRequestFailedMessage) {
const errorData = parseErrorText(apiRequestFailedMessage)
if (errorData?.code === "insufficient_credits") {
return <span style={{ color: errorColor, fontWeight: "bold" }}>Credit Limit Reached</span>
}
return <span style={{ color: errorColor, fontWeight: "bold" }}>API Request Failed</span>
}
// New: Check for retryStatus to modify the title
@@ -1720,12 +1720,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
style={{
bottom: `calc(100vh - ${menuPosition}px + 6px)`,
}}>
<ApiOptions
showModelOptions={true}
apiErrorMessage={undefined}
modelIdErrorMessage={undefined}
isPopup={true}
/>
<ApiOptions showModelOptions={true} modelIdErrorMessage={undefined} isPopup={true} />
</ModelSelectorTooltip>
)}
</ModelContainer>
@@ -22,10 +22,14 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
// We have to divide because the balance is stored in microcredits
return (
<div className="p-2 border-none rounded-md mb-2 bg-[var(--vscode-textBlockQuote-background)]">
<div className="mb-2">{message}</div>
<div className="mb-3">
<div className="text-[var(--vscode-foreground)]">
Current Balance: <span className="font-bold">${currentBalance.toFixed(4)}</span>
<div className="mb-3 font-azeret-mono">
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "8px" }}>{message}</div>
<div style={{ marginBottom: "12px" }}>
<div style={{ color: "var(--vscode-foreground)" }}>
Current Balance: <span style={{ fontWeight: "bold" }}>{currentBalance.toFixed(2)}</span>
</div>
<div style={{ color: "var(--vscode-foreground)" }}>Total Spent: {totalSpent.toFixed(2)}</div>
<div style={{ color: "var(--vscode-foreground)" }}>Total Promotions: {totalPromotions.toFixed(2)}</div>
</div>
</div>
@@ -35,7 +39,7 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
width: "100%",
marginBottom: "8px",
}}>
<span className="codicon codicon-credit-card mr-0.5 text-sm" />
<span className="codicon codicon-credit-card mr-[6px] text-[14px]" />
Buy Credits
</VSCodeButtonLink>
@@ -1,14 +1,12 @@
import { useExtensionState } from "@/context/ExtensionStateContext"
import { ModelsServiceClient } from "@/services/grpc-client"
import { StringRequest } from "@shared/proto/common"
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { ModelsServiceClient, StateServiceClient } from "@/services/grpc-client"
import { BooleanRequest, StringRequest } from "@shared/proto/common"
import { VSCodeButton, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useEffect, useState } from "react"
import { useInterval } from "react-use"
import styled from "styled-components"
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
import { normalizeApiConfiguration } from "./utils/providerUtils"
import { ClineProvider } from "./providers/ClineProvider"
import { OpenRouterProvider } from "./providers/OpenRouterProvider"
import { MistralProvider } from "./providers/MistralProvider"
@@ -37,8 +35,10 @@ import { LiteLlmProvider } from "./providers/LiteLlmProvider"
import { VSCodeLmProvider } from "./providers/VSCodeLmProvider"
import { LMStudioProvider } from "./providers/LMStudioProvider"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
import { GroqProvider } from "./providers/GroqProvider"
interface ApiOptionsProps {
showSubmitButton?: boolean
showModelOptions: boolean
apiErrorMessage?: string
modelIdErrorMessage?: string
@@ -69,7 +69,7 @@ declare module "vscode" {
}
}
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, isPopup }: ApiOptionsProps) => {
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, isPopup, showSubmitButton }: ApiOptionsProps) => {
// Use full context state for immediate save payload
const { apiConfiguration, uriScheme } = useExtensionState()
@@ -79,6 +79,14 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
const [ollamaModels, setOllamaModels] = useState<string[]>([])
const handleSubmit = async () => {
try {
await StateServiceClient.setWelcomeViewCompleted(BooleanRequest.create({ value: true }))
} catch (error) {
console.error("Failed to update API configuration or complete welcome view:", error)
}
}
// Poll ollama/vscode-lm models
const requestLocalModels = useCallback(async () => {
if (selectedProvider === "ollama") {
@@ -122,7 +130,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<VSCodeDropdown
id="api-provider"
value={selectedProvider}
onChange={(e: any) => handleFieldChange("apiProvider", e.target.value)}
onChange={(e: any) => {
handleFieldChange("apiProvider", e.target.value)
}}
style={{
minWidth: 130,
position: "relative",
@@ -132,12 +142,13 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<VSCodeOption value="anthropic">Anthropic</VSCodeOption>
<VSCodeOption value="claude-code">Claude Code</VSCodeOption>
<VSCodeOption value="bedrock">Amazon Bedrock</VSCodeOption>
<VSCodeOption value="openai">OpenAI Compatible</VSCodeOption>
<VSCodeOption value="openai-native">OpenAI</VSCodeOption>
<VSCodeOption value="vertex">GCP Vertex AI</VSCodeOption>
<VSCodeOption value="gemini">Google Gemini</VSCodeOption>
<VSCodeOption value="groq">Groq</VSCodeOption>
<VSCodeOption value="deepseek">DeepSeek</VSCodeOption>
<VSCodeOption value="openai">OpenAI Compatible</VSCodeOption>
<VSCodeOption value="mistral">Mistral</VSCodeOption>
<VSCodeOption value="openai-native">OpenAI</VSCodeOption>
<VSCodeOption value="vscode-lm">VS Code LM API</VSCodeOption>
<VSCodeOption value="requesty">Requesty</VSCodeOption>
<VSCodeOption value="fireworks">Fireworks</VSCodeOption>
@@ -231,6 +242,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
{apiConfiguration && selectedProvider === "vscode-lm" && <VSCodeLmProvider />}
{apiConfiguration && selectedProvider === "groq" && (
<GroqProvider showModelOptions={showModelOptions} isPopup={isPopup} />
)}
{apiConfiguration && selectedProvider === "litellm" && (
<LiteLlmProvider showModelOptions={showModelOptions} isPopup={isPopup} />
)}
@@ -283,6 +297,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
{modelIdErrorMessage}
</p>
)}
{showSubmitButton && (
<VSCodeButton onClick={handleSubmit} disabled={apiErrorMessage != null} className="mt-0.75" title="Submit">
Let's go!
</VSCodeButton>
)}
</div>
)
}
@@ -0,0 +1,260 @@
import { EmptyRequest } from "@shared/proto/common"
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
import { useMount } from "react-use"
import { groqDefaultModelId, groqModels } from "@shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { ModelsServiceClient } from "../../services/grpc-client"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import { highlight } from "../history/HistoryView"
import { ModelInfoView } from "./common/ModelInfoView"
import { normalizeApiConfiguration } from "./utils/providerUtils"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
export interface GroqModelPickerProps {
isPopup?: boolean
}
const GroqModelPicker: React.FC<GroqModelPickerProps> = ({ isPopup }) => {
const { apiConfiguration, groqModels: dynamicGroqModels, setGroqModels } = useExtensionState()
const { handleFieldsChange } = useApiConfigurationHandlers()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.groqModelId || groqDefaultModelId)
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
const handleModelChange = (newModelId: string) => {
// Use dynamic models if available, otherwise fall back to static models
const modelInfo = dynamicGroqModels?.[newModelId] || groqModels[newModelId as keyof typeof groqModels]
handleFieldsChange({
groqModelId: newModelId,
groqModelInfo: modelInfo,
})
setSearchTerm(newModelId)
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
useMount(() => {
ModelsServiceClient.refreshGroqModels(EmptyRequest.create({}))
.then((response) => {
setGroqModels({
[groqDefaultModelId]: groqModels[groqDefaultModelId],
...response.models,
})
})
.catch((err) => {
console.error("Failed to refresh Groq models:", err)
})
})
// Debounce search term to reduce re-renders
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm)
}, 300)
return () => clearTimeout(timer)
}, [searchTerm])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownVisible(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
const allGroqModels = useMemo(() => {
// Merge static models with dynamic models, with dynamic taking precedence
return { ...groqModels, ...(dynamicGroqModels || {}) }
}, [dynamicGroqModels])
const modelIds = useMemo(() => {
return Object.keys(allGroqModels).sort((a, b) => a.localeCompare(b))
}, [allGroqModels])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
id,
html: id,
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = debouncedSearchTerm
? highlight(fuse.search(debouncedSearchTerm), "model-item-highlight")
: searchableItems
return results
}, [searchableItems, debouncedSearchTerm, fuse])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) return
switch (event.key) {
case "ArrowDown":
event.preventDefault()
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
break
case "ArrowUp":
event.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case "Enter":
event.preventDefault()
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
handleModelChange(modelSearchResults[selectedIndex].id)
setIsDropdownVisible(false)
}
break
case "Escape":
setIsDropdownVisible(false)
setSelectedIndex(-1)
break
}
}
const hasInfo = useMemo(() => {
try {
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
} catch {
return false
}
}, [modelIds, searchTerm])
useEffect(() => {
setSelectedIndex(-1)
if (dropdownListRef.current) {
dropdownListRef.current.scrollTop = 0
}
}, [searchTerm])
useEffect(() => {
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
block: "nearest",
behavior: "smooth",
})
}
}, [selectedIndex])
return (
<div className="w-full">
<style>
{`
.model-item-highlight {
background-color: var(--vscode-editor-findMatchHighlightBackground);
color: inherit;
}
`}
</style>
<div className="flex flex-col">
<label htmlFor="model-search">
<span className="font-medium">Model</span>
</label>
<div ref={dropdownRef} className="relative w-full">
<VSCodeTextField
id="model-search"
placeholder="Search and select a model..."
value={searchTerm}
onInput={(e) => {
setSearchTerm((e.target as HTMLInputElement)?.value || "")
setIsDropdownVisible(true)
}}
onFocus={() => setIsDropdownVisible(true)}
onKeyDown={handleKeyDown}
style={{
width: "100%",
zIndex: GROQ_MODEL_PICKER_Z_INDEX,
position: "relative",
}}>
{searchTerm && (
<div
className="input-icon-button codicon codicon-close flex justify-center items-center h-full"
aria-label="Clear search"
onClick={() => {
setSearchTerm("")
setIsDropdownVisible(true)
}}
slot="end"
/>
)}
</VSCodeTextField>
{isDropdownVisible && (
<div
ref={dropdownListRef}
className="absolute top-[calc(100%-3px)] left-0 w-[calc(100%-2px)] max-h-[200px] overflow-y-auto border border-[var(--vscode-list-activeSelectionBackground)] rounded-b-[3px]"
style={{
backgroundColor: "var(--vscode-dropdown-background)",
zIndex: GROQ_MODEL_PICKER_Z_INDEX - 1,
}}>
{modelSearchResults.map((item, index) => (
<div
key={item.id}
ref={(el: HTMLDivElement | null) => (itemRefs.current[index] = el)}
className={`px-2.5 py-1.5 cursor-pointer break-all whitespace-normal hover:bg-[var(--vscode-list-activeSelectionBackground)] ${
index === selectedIndex ? "bg-[var(--vscode-list-activeSelectionBackground)]" : ""
}`}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
handleModelChange(item.id)
setIsDropdownVisible(false)
}}
dangerouslySetInnerHTML={{
__html: item.html,
}}
/>
))}
</div>
)}
</div>
</div>
{hasInfo ? (
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
) : (
<p className="text-xs mt-0 text-[var(--vscode-descriptionForeground)]">
<>
The extension automatically fetches the latest list of models available on{" "}
<VSCodeLink className="inline text-inherit" href="https://console.groq.com/docs/models">
Groq.
</VSCodeLink>
If you're unsure which model to choose, Cline works best with{" "}
<VSCodeLink className="inline text-inherit" onClick={() => handleModelChange("llama-3.3-70b-versatile")}>
llama-3.3-70b-versatile.
</VSCodeLink>
</>
</p>
)}
</div>
)
}
export const GROQ_MODEL_PICKER_Z_INDEX = 1_000
export default GroqModelPicker
@@ -33,6 +33,7 @@ export const ApiKeyField = ({
style={{ width: "100%" }}
type="password"
onInput={(e: any) => setLocalValue(e.target.value)}
required={true}
placeholder={placeholder}>
<span style={{ fontWeight: 500 }}>{providerName} API Key</span>
</VSCodeTextField>
@@ -0,0 +1,33 @@
import { ApiKeyField } from "../common/ApiKeyField"
import GroqModelPicker from "../GroqModelPicker"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
import { useExtensionState } from "@/context/ExtensionStateContext"
/**
* Props for the GroqProvider component
*/
interface GroqProviderProps {
showModelOptions: boolean
isPopup?: boolean
}
/**
* The Groq provider configuration component
*/
export const GroqProvider = ({ showModelOptions, isPopup }: GroqProviderProps) => {
const { apiConfiguration } = useExtensionState()
const { handleFieldChange } = useApiConfigurationHandlers()
return (
<div>
<ApiKeyField
initialValue={apiConfiguration?.groqApiKey || ""}
onChange={(value) => handleFieldChange("groqApiKey", value)}
providerName="Groq"
signupUrl="https://console.groq.com/keys"
/>
{showModelOptions && <GroqModelPicker isPopup={isPopup} />}
</div>
)
}
@@ -44,6 +44,8 @@ import {
sapAiCoreDefaultModelId,
claudeCodeDefaultModelId,
claudeCodeModels,
groqModels,
groqDefaultModelId,
} from "@shared/api"
/**
@@ -178,6 +180,14 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration):
return getProviderData(sambanovaModels, sambanovaDefaultModelId)
case "cerebras":
return getProviderData(cerebrasModels, cerebrasDefaultModelId)
case "groq":
const result = {
selectedProvider: provider,
selectedModelId: apiConfiguration?.groqModelId || groqDefaultModelId,
selectedModelInfo: apiConfiguration?.groqModelInfo || groqModels[groqDefaultModelId],
}
return result
case "sapaicore":
return getProviderData(sapAiCoreModels, sapAiCoreDefaultModelId)
default:
@@ -3,10 +3,10 @@ import { ModelsServiceClient } from "@/services/grpc-client"
import { ApiConfiguration } from "@shared/api"
import { convertApiConfigurationToProto } from "@shared/proto-conversions/models/api-configuration-conversion"
import { UpdateApiConfigurationRequest } from "@shared/proto/models"
import { useCallback } from "react"
export const useApiConfigurationHandlers = () => {
const { apiConfiguration } = useExtensionState()
const { apiConfiguration, uriScheme } = useExtensionState()
/**
* Updates a single field in the API configuration.
*
@@ -17,21 +17,24 @@ export const useApiConfigurationHandlers = () => {
* @param field - The field key to update
* @param value - The new value for the field
*/
const handleFieldChange = <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => {
const updatedConfig = {
...apiConfiguration,
[field]: value,
}
const handleFieldChange = useCallback(
<K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => {
const updatedConfig = {
...apiConfiguration,
[field]: value,
}
const protoConfig = convertApiConfigurationToProto(updatedConfig)
ModelsServiceClient.updateApiConfigurationProto(
UpdateApiConfigurationRequest.create({
apiConfiguration: protoConfig,
}),
).catch((error) => {
console.error(`Failed to update API configuration field ${field}:`, error)
})
}
const protoConfig = convertApiConfigurationToProto(updatedConfig)
ModelsServiceClient.updateApiConfigurationProto(
UpdateApiConfigurationRequest.create({
apiConfiguration: protoConfig,
}),
).catch((error) => {
console.error(`Failed to update API configuration field ${field}:`, error)
})
},
[apiConfiguration],
)
/**
* Updates multiple fields in the API configuration at once.
@@ -42,21 +45,24 @@ export const useApiConfigurationHandlers = () => {
*
* @param updates - An object containing the fields to update and their new values
*/
const handleFieldsChange = (updates: Partial<ApiConfiguration>) => {
const updatedConfig = {
...apiConfiguration,
...updates,
}
const handleFieldsChange = useCallback(
(updates: Partial<ApiConfiguration>) => {
const updatedConfig = {
...apiConfiguration,
...updates,
}
const protoConfig = convertApiConfigurationToProto(updatedConfig)
ModelsServiceClient.updateApiConfigurationProto(
UpdateApiConfigurationRequest.create({
apiConfiguration: protoConfig,
}),
).catch((error) => {
console.error("Failed to update API configuration fields:", error)
})
}
const protoConfig = convertApiConfigurationToProto(updatedConfig)
ModelsServiceClient.updateApiConfigurationProto(
UpdateApiConfigurationRequest.create({
apiConfiguration: protoConfig,
}),
).catch((error) => {
console.error("Failed to update API configuration fields:", error)
})
},
[apiConfiguration],
)
return { handleFieldChange, handleFieldsChange }
return { handleFieldChange, handleFieldsChange, uriScheme, apiConfiguration }
}
@@ -1,37 +1,19 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { useEffect, useState, memo } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { validateApiConfiguration } from "@/utils/validate"
import { useState, memo } from "react"
import ApiOptions from "@/components/settings/ApiOptions"
import ClineLogoWhite from "@/assets/ClineLogoWhite"
import { AccountServiceClient, ModelsServiceClient, StateServiceClient } from "@/services/grpc-client"
import { EmptyRequest, BooleanRequest } from "@shared/proto/common"
import { AccountServiceClient } from "@/services/grpc-client"
import { EmptyRequest } from "@shared/proto/common"
const WelcomeView = memo(() => {
const { apiConfiguration } = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [showApiOptions, setShowApiOptions] = useState(false)
const disableLetsGoButton = apiErrorMessage != null
const handleLogin = () => {
AccountServiceClient.accountLoginClicked(EmptyRequest.create()).catch((err) =>
console.error("Failed to get login URL:", err),
)
}
const handleSubmit = async () => {
try {
await StateServiceClient.setWelcomeViewCompleted(BooleanRequest.create({ value: true }))
} catch (error) {
console.error("Failed to update API configuration or complete welcome view:", error)
}
}
useEffect(() => {
setApiErrorMessage(validateApiConfiguration(apiConfiguration))
}, [apiConfiguration])
return (
<div className="fixed inset-0 p-0 flex flex-col">
<div className="h-full px-5 overflow-auto">
@@ -67,16 +49,7 @@ const WelcomeView = memo(() => {
</VSCodeButton>
)}
<div className="mt-4.5">
{showApiOptions && (
<div>
<ApiOptions showModelOptions={false} />
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} className="mt-0.75">
Let's go!
</VSCodeButton>
</div>
)}
</div>
<div className="mt-4.5">{showApiOptions && <ApiOptions showModelOptions={false} showSubmitButton={true} />}</div>
</div>
</div>
)
@@ -24,6 +24,8 @@ import {
openRouterDefaultModelInfo,
requestyDefaultModelId,
requestyDefaultModelInfo,
groqDefaultModelId,
groqModels,
} from "../../../src/shared/api"
import { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/shared/mcp"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
@@ -38,6 +40,7 @@ interface ExtensionStateContextType extends ExtensionState {
openRouterModels: Record<string, ModelInfo>
openAiModels: string[]
requestyModels: Record<string, ModelInfo>
groqModels: Record<string, ModelInfo>
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
filePaths: string[]
@@ -58,6 +61,7 @@ interface ExtensionStateContextType extends ExtensionState {
setChatSettings: (value: ChatSettings) => void
setMcpServers: (value: McpServer[]) => void
setRequestyModels: (value: Record<string, ModelInfo>) => void
setGroqModels: (value: Record<string, ModelInfo>) => void
setGlobalClineRulesToggles: (toggles: Record<string, boolean>) => void
setLocalClineRulesToggles: (toggles: Record<string, boolean>) => void
setLocalCursorRulesToggles: (toggles: Record<string, boolean>) => void
@@ -205,6 +209,9 @@ export const ExtensionStateContextProvider: React.FC<{
const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
[requestyDefaultModelId]: requestyDefaultModelInfo,
})
const [groqModelsState, setGroqModels] = useState<Record<string, ModelInfo>>({
[groqDefaultModelId]: groqModels[groqDefaultModelId],
})
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState<McpMarketplaceCatalog>({ items: [] })
@@ -249,7 +256,6 @@ export const ExtensionStateContextProvider: React.FC<{
if (response.stateJson) {
try {
const stateData = JSON.parse(response.stateJson) as ExtensionState
console.log("[DEBUG] parsed state JSON, updating state")
setState((prevState) => {
// Versioning logic for autoApprovalSettings
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
@@ -631,6 +637,7 @@ export const ExtensionStateContextProvider: React.FC<{
openRouterModels,
openAiModels,
requestyModels,
groqModels: groqModelsState,
mcpServers,
mcpMarketplaceCatalog,
filePaths,
@@ -670,6 +677,7 @@ export const ExtensionStateContextProvider: React.FC<{
})),
setMcpServers: (mcpServers: McpServer[]) => setMcpServers(mcpServers),
setRequestyModels: (models: Record<string, ModelInfo>) => setRequestyModels(models),
setGroqModels: (models: Record<string, ModelInfo>) => setGroqModels(models),
setMcpMarketplaceCatalog: (catalog: McpMarketplaceCatalog) => setMcpMarketplaceCatalog(catalog),
setShowMcp,
closeMcpView,