Compare commits

..

1 Commits

Author SHA1 Message Date
celestial-vault fbb11b2931 add webview type checking to check-types 2025-07-15 14:19:36 -07:00
78 changed files with 419 additions and 5827 deletions
-88
View File
@@ -1,88 +0,0 @@
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,10 +68,6 @@ 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,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
files: "{out/**/*.test.js,src/**/*.test.js}",
mocha: {
ui: "bdd",
timeout: 20000, // Maximum time (in ms) that a test can run before failing
+4 -21
View File
@@ -1,16 +1,15 @@
# Default
.vscode/**
.vscode-test/**
out/
dist-standalone/
node_modules/
out/**
dist-standalone/**
node_modules/**
src/**
standalone/**
.gitignore
.yarnrc
esbuild.js
vsc-extension-quickstart.md
tsconfig*.json
**/tsconfig.json
**/.eslintrc.json
**/*.map
**/*.ts
@@ -24,17 +23,6 @@ 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/**
@@ -59,8 +47,3 @@ old_docs/**
# Include icons
!assets/icons/**
# Ignore E2E build files
e2e-build.js
e2e.vsix
test-results/
-7
View File
@@ -1,12 +1,5 @@
# 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, 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.
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.
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 or Pro subscription with Cline instead of paying per token. Learn how to set up and configure the Claude Code provider."
description: "Use your Claude Max 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 or Pro, 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, this means you can use Claude in Cline without paying extra API costs.
<Frame>
<img
+1 -12
View File
@@ -5,7 +5,6 @@ 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"
/**
@@ -161,18 +160,8 @@ 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 : e2eBuild ? e2eBuildConfig : extensionConfig
const config = standalone ? standaloneConfig : 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,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
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
```
This will build the eval script, run it, and then open the streamlit dashboard to show the results.
+124 -3891
View File
File diff suppressed because it is too large Load Diff
+3 -7
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.5",
"version": "3.19.4",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -330,7 +330,7 @@
"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 scripts/build-proto.mjs && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs",
"protos": "node proto/build-proto.js && 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",
@@ -345,8 +345,6 @@
"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",
@@ -385,8 +383,7 @@
"@typescript-eslint/parser": "^7.18.0",
"@typescript-eslint/utils": "^8.33.0",
"@vscode/test-cli": "^0.0.10",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.6.0",
"@vscode/test-electron": "^2.4.1",
"chai": "^4.3.10",
"chalk": "^5.3.0",
"esbuild": "^0.25.0",
@@ -428,7 +425,6 @@
"@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
@@ -1,17 +0,0 @@
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"),
})
@@ -9,22 +9,22 @@ import chalk from "chalk"
import os from "os"
import { createRequire } from "module"
import { serviceNameMap, hostServiceNameMap } from "./build-proto-config.mjs"
import { serviceNameMap, hostServiceNameMap } from "./build-proto-config.js"
const require = createRequire(import.meta.url)
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
const SCRIPT_NAME = path.relative(process.cwd(), fileURLToPath(import.meta.url))
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
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 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 isWindows = process.platform === "win32"
const TS_PROTO_PLUGIN = isWindows
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
? path.join(ROOT_DIR, "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,7 +37,12 @@ const TS_PROTO_OPTIONS = [
]
// Service directories derived from imported serviceNameMap
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join("src/core/controller", serviceKey))
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),
)
async function main() {
console.log(chalk.bold.blue("Starting Protocol Buffer code generation..."))
@@ -56,8 +61,8 @@ async function main() {
await ensureProtoFilesExist()
// Process all proto files
const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR, realpath: true })
console.log(chalk.cyan(`Processing ${protoFiles.length} proto files from`), PROTO_DIR)
const protoFiles = await globby("**/*.proto", { cwd: SCRIPT_DIR, realpath: true })
console.log(chalk.cyan(`Processing ${protoFiles.length} proto files from`), SCRIPT_DIR)
tsProtoc(TS_OUT_DIR, protoFiles, TS_PROTO_OPTIONS)
// grpc-js is used to generate service impls for the ProtoBus service.
@@ -68,7 +73,7 @@ async function main() {
const descriptorFile = path.join(DESCRIPTOR_OUT_DIR, "descriptor_set.pb")
const descriptorProtocCommand = [
PROTOC,
`--proto_path="${PROTO_DIR}"`,
`--proto_path="${SCRIPT_DIR}"`,
`--descriptor_set_out="${descriptorFile}"`,
"--include_imports",
...protoFiles,
@@ -84,12 +89,11 @@ 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 generateProtoBusServiceConfig()
await generateProtoBusMethodRegistrations()
await generateProtoBusGrpcClientConfig()
await generateHostBridgeServiceConfig()
await generateHostBridgeMethodRegistrations()
await generateMethodRegistrations()
await generateHostMethodRegistrations()
await generateServiceConfig()
await generateHostServiceConfig()
await generateGrpcClientConfig()
console.log(chalk.bold.blue("Finished Protocol Buffer code generation."))
}
@@ -98,7 +102,7 @@ async function tsProtoc(outDir, protoFiles, protoOptions) {
// Build the protoc command with proper path handling for cross-platform
const command = [
PROTOC,
`--proto_path="${PROTO_DIR}"`,
`--proto_path="${SCRIPT_DIR}"`,
`--plugin=protoc-gen-ts_proto="${TS_PROTO_PLUGIN}"`,
`--ts_proto_out="${outDir}"`,
`--ts_proto_opt=${protoOptions.join(",")} `,
@@ -118,7 +122,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 generateProtoBusGrpcClientConfig() {
async function generateGrpcClientConfig() {
log_verbose(chalk.cyan("Generating gRPC client configuration..."))
const serviceImports = []
@@ -143,7 +147,7 @@ async function generateProtoBusGrpcClientConfig() {
// Generate the file content
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by ${SCRIPT_NAME}
// Generated by proto/build-proto.js
import { createGrpcClient } from "./grpc-client-base"
${serviceImports.join("\n")}
@@ -154,7 +158,7 @@ export {
${serviceExports.join(",\n\t")}
}`
const filePath = path.resolve("webview-ui/src/services/grpc-client.ts")
const filePath = path.join(ROOT_DIR, "webview-ui", "src", "services", "grpc-client.ts")
await writeFileWithMkdirs(filePath, content)
log_verbose(chalk.green(`Generated gRPC client at ${filePath}`))
}
@@ -217,12 +221,12 @@ async function parseProtoForStreamingMethods(protoFiles, scriptDir) {
return streamingMethodsMap
}
async function generateProtoBusMethodRegistrations() {
async function generateMethodRegistrations() {
log_verbose(chalk.cyan("Generating method registration files..."))
// Parse proto files for streaming methods
const protoFiles = await globby("*.proto", { cwd: PROTO_DIR })
const streamingMethodsMap = await parseProtoForStreamingMethods(protoFiles, PROTO_DIR)
const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR })
const streamingMethodsMap = await parseProtoForStreamingMethods(protoFiles, SCRIPT_DIR)
for (const serviceDir of serviceDirs) {
const serviceName = path.basename(serviceDir)
@@ -239,7 +243,7 @@ async function generateProtoBusMethodRegistrations() {
// Create the methods.ts file with header
let methodsContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by ${SCRIPT_NAME}
// Generated by proto/build-proto.js
// Import all method implementations
import { registerMethod } from "./index"\n`
@@ -288,7 +292,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 ${SCRIPT_NAME}
// Generated by proto/build-proto.js
import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service"
import { StreamingResponseHandler } from "../grpc-handler"
@@ -323,7 +327,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 generateProtoBusServiceConfig() {
async function generateServiceConfig() {
log_verbose(chalk.cyan("Generating service configuration file..."))
const serviceImports = []
@@ -343,7 +347,7 @@ async function generateProtoBusServiceConfig() {
}
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by ${SCRIPT_NAME}
// Generated by proto/build-proto.js
import { Controller } from "./index"
import { StreamingResponseHandler } from "./grpc-handler"
@@ -363,7 +367,7 @@ export interface ServiceHandlerConfig {
export const serviceHandlers: Record<string, ServiceHandlerConfig> = {${serviceConfigs.join(",")}
};`
const configPath = path.resolve("src/core/controller/grpc-service-config.ts")
const configPath = path.join(ROOT_DIR, "src", "core", "controller", "grpc-service-config.ts")
await writeFileWithMkdirs(configPath, content)
log_verbose(chalk.green(`Generated service configuration at ${configPath}`))
}
@@ -376,7 +380,7 @@ async function ensureProtoFilesExist() {
log_verbose(chalk.cyan("Checking for missing proto files..."))
// Get existing proto files
const existingProtoFiles = await globby("*.proto", { cwd: PROTO_DIR })
const existingProtoFiles = await globby("*.proto", { cwd: SCRIPT_DIR })
const existingProtoServices = existingProtoFiles.map((file) => path.basename(file, ".proto"))
// Check each service in serviceNameMap
@@ -413,7 +417,7 @@ service ${serviceClassName} {
`
// Write the template proto file
const protoFilePath = path.join(PROTO_DIR, `${serviceName}.proto`)
const protoFilePath = path.join(SCRIPT_DIR, `${serviceName}.proto`)
await fs.writeFile(protoFilePath, protoContent)
log_verbose(chalk.green(`Created template proto file at ${protoFilePath}`))
}
@@ -423,22 +427,17 @@ service ${serviceClassName} {
/**
* Generate method registration files for host services
*/
async function generateHostBridgeMethodRegistrations() {
async function generateHostMethodRegistrations() {
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(PROTO_DIR, "host") })
const streamingMethodsMap = await parseProtoForStreamingMethods(hostProtoFiles, path.join(PROTO_DIR, "host"))
const hostProtoFiles = await globby("*.proto", { cwd: path.join(SCRIPT_DIR, "host") })
const streamingMethodsMap = await parseProtoForStreamingMethods(hostProtoFiles, path.join(SCRIPT_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}...`))
@@ -450,7 +449,7 @@ async function generateHostBridgeMethodRegistrations() {
// Create the methods.ts file with header
let methodsContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated ${SCRIPT_NAME}
// Generated by proto/build-proto.js
// Import all method implementations
import { registerMethod } from "./index"\n`
@@ -458,7 +457,7 @@ import { registerMethod } from "./index"\n`
// Import implementations directly
for (const file of implementationFiles) {
const baseName = path.basename(file, ".ts")
methodsContent += `import { ${baseName} } from "@hosts/vscode/hostbridge/${serviceName}/${baseName}"\n`
methodsContent += `import { ${baseName} } from "./${baseName}"\n`
}
// Add streaming methods information
@@ -492,17 +491,17 @@ export function registerAllMethods(): void {
methodsContent += `}`
// Write the methods.ts file
const registryFile = path.join(outputDir, "methods.ts")
const registryFile = path.join(serviceDir, "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 ${SCRIPT_NAME}
// Generated by proto/build-proto.js
import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "@hosts/vscode/hostbridge-grpc-service"
import { StreamingResponseHandler } from "@hosts/vscode/hostbridge-grpc-handler"
import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../host-grpc-service"
import { StreamingResponseHandler } from "../host-grpc-handler"
import { registerAllMethods } from "./methods"
// Create ${serviceName} service registry
@@ -522,7 +521,7 @@ export const isStreamingMethod = ${serviceName}Service.isStreamingMethod
registerAllMethods()`
// Write the index.ts file
const indexFile = path.join(outputDir, "index.ts")
const indexFile = path.join(serviceDir, "index.ts")
await writeFileWithMkdirs(indexFile, indexContent)
log_verbose(chalk.green(`Generated ${indexFile}`))
}
@@ -533,7 +532,7 @@ registerAllMethods()`
/**
* Generate a service configuration file for host services
*/
async function generateHostBridgeServiceConfig() {
async function generateHostServiceConfig() {
log_verbose(chalk.cyan("Generating host service configuration file..."))
const serviceImports = []
@@ -543,7 +542,7 @@ async function generateHostBridgeServiceConfig() {
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 "@generated/hosts/vscode/hostbridge/${dirName}/index"`,
`import { handle${capitalizedName}ServiceRequest, handle${capitalizedName}ServiceStreamingRequest } from "./${dirName}/index"`,
)
serviceConfigs.push(`
"${fullServiceName}": {
@@ -553,9 +552,9 @@ async function generateHostBridgeServiceConfig() {
}
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by ${SCRIPT_NAME}
// Generated by proto/build-proto.js
import { StreamingResponseHandler } from "@/hosts/vscode/hostbridge-grpc-handler"
import { StreamingResponseHandler } from "./host-grpc-handler"
${serviceImports.join("\n")}
/**
@@ -572,7 +571,7 @@ export interface HostServiceHandlerConfig {
export const hostServiceHandlers: Record<string, HostServiceHandlerConfig> = {${serviceConfigs.join(",")}
};`
const filePath = "src/generated/hosts/vscode/hostbridge-grpc-service-config.ts"
const filePath = path.join(ROOT_DIR, "src/hosts/vscode/host-grpc-service-config.ts")
await writeFileWithMkdirs(filePath, content)
log_verbose(chalk.green(`Generated host service configuration at ${filePath}`))
}
@@ -584,33 +583,15 @@ async function cleanup() {
for (const file of existingFiles) {
await fs.unlink(path.join(TS_OUT_DIR, file))
}
await rmdir("src/generated")
await rmdir(path.join(ROOT_DIR, "src", "generated"))
// Clean up generated files that were moved.
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", "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/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 })
}
await fs.rm(path.join(ROOT_DIR, "src/standalone/server-setup.ts"), { force: true })
}
/**
+1 -15
View File
@@ -10,7 +10,6 @@ import "common.proto";
service DiffService {
// Open the diff view/editor.
rpc openDiff(OpenDiffRequest) returns (OpenDiffResponse);
rpc replaceText(ReplaceTextRequest) returns (ReplaceTextResponse);
}
message OpenDiffRequest {
@@ -22,18 +21,5 @@ message OpenDiffRequest {
}
message OpenDiffResponse {
// 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
// TODO(sfortune) the host needs to return a unique id for the diff editor.
}
+3 -9
View File
@@ -23,8 +23,6 @@ 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
@@ -122,10 +120,9 @@ enum ApiProvider {
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
GROQ = 24;
SAPAICORE = 25;
CLAUDE_CODE = 26;
MOONSHOT = 27;
SAPAICORE = 24;
CLAUDE_CODE = 25;
MOONSHOT = 26;
}
// Model info for OpenAI-compatible models
@@ -243,7 +240,4 @@ 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
@@ -0,0 +1,3 @@
{
"type": "module"
}
-8
View File
@@ -28,7 +28,6 @@ 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
@@ -222,13 +221,6 @@ 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,
+8 -1
View File
@@ -69,6 +69,12 @@ 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
@@ -179,11 +185,12 @@ export class ClineHandler implements ApiHandler {
}
} catch (error) {
console.error("Cline API Error:", error)
const requestId = error?.request_id ? `\n | Request ID: ${error.request_id}` : ""
const requestId = error?.request_id ? ` (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))
}
}
-290
View File
@@ -1,290 +0,0 @@
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/hostbridge/client/host-grpc-client"
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
+5
View File
@@ -461,6 +461,11 @@ 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")
@@ -1,254 +0,0 @@
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,17 +42,6 @@ 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,7 +13,6 @@ 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,7 +26,6 @@ export type SecretKey =
| "cerebrasApiKey"
| "sapAiCoreClientId"
| "sapAiCoreClientSecret"
| "groqApiKey"
export type GlobalStateKey =
| "awsRegion"
@@ -118,7 +117,5 @@ export type GlobalStateKey =
| "previousModeAwsBedrockCustomSelected"
| "previousModeAwsBedrockCustomModelBaseId"
| "previousModeSapAiCoreModelId"
| "groqModelId"
| "groqModelInfo"
export type LocalStateKey = "localClineRulesToggles" | "localCursorRulesToggles" | "localWindsurfRulesToggles" | "workflowToggles"
-17
View File
@@ -167,7 +167,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
moonshotApiKey,
nebiusApiKey,
planActSeparateModelsSettingRaw,
@@ -189,8 +188,6 @@ 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>,
@@ -247,7 +244,6 @@ 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>,
@@ -269,8 +265,6 @@ 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
@@ -345,7 +339,6 @@ 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
@@ -449,9 +442,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
groqModelId,
groqModelInfo,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
@@ -564,9 +554,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
clineAccountId,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
groqModelId,
groqModelInfo,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
@@ -605,8 +592,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
requestyModelInfo,
togetherModelId,
fireworksModelId,
groqModelId,
groqModelInfo,
sapAiCoreModelId,
// Global state updates (27 keys)
@@ -667,7 +652,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
moonshotApiKey,
nebiusApiKey,
sapAiCoreClientId,
@@ -712,7 +696,6 @@ export async function resetGlobalState(context: vscode.ExtensionContext) {
"xaiApiKey",
"sambanovaApiKey",
"cerebrasApiKey",
"groqApiKey",
"moonshotApiKey",
"nebiusApiKey",
]
+2 -14
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 { extractErrorDetails, formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
import { formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
import { createDiffViewProvider, getHostBridgeProvider } from "@/hosts/host-providers"
export const USE_EXPERIMENTAL_CLAUDE4_FEATURES = false
@@ -1575,9 +1575,8 @@ 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 = modelInfo.info.supportsImages ?? false
const modelSupportsBrowserUse = this.api.getModel().info.supportsImages ?? false
const supportsBrowserUse = modelSupportsBrowserUse && !disableBrowserTool // only enable browser use if the model supports it and the user hasn't disabled it
@@ -1662,17 +1661,6 @@ 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(),
+2 -9
View File
@@ -6,20 +6,13 @@ import { calculateApiCostAnthropic } from "@/utils/cost"
import { ApiHandler } from "@/api"
export function formatErrorWithStatusCode(error: any): string {
const { statusCode, message } = extractErrorDetails(error)
const statusCode = error.status || error.statusCode || (error.response && error.response.status)
const message = error.message ?? JSON.stringify(serializeError(error), null, 2)
// 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,
@@ -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 "@core/webview"
import { sendDidBecomeVisibleEvent } from "@core/controller/ui/subscribeToDidBecomeVisible"
import { WebviewProvider } from "."
import { sendDidBecomeVisibleEvent } from "../controller/ui/subscribeToDidBecomeVisible"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
+7 -10
View File
@@ -262,16 +262,13 @@ export abstract class WebviewProvider {
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
// 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.",
}),
)
}
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()
}
+27 -4
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/hostbridge/client/host-grpc-client"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
import { VscodeWebviewProvider } from "./core/webview/VscodeWebviewProvider"
import { ExtensionContext } from "vscode"
import { AuthService } from "./services/auth/AuthService"
import { writeTextToClipboard, readTextFromClipboard } from "@/utils/env"
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import { VscodeDiffViewProvider } from "./integrations/editor/VscodeDiffViewProvider"
import { getHostBridgeProvider } from "@hosts/host-providers"
import { ShowMessageRequest, ShowMessageType } from "./shared/proto/host/window"
/*
@@ -301,12 +301,35 @@ 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:", { 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
}
}
if (token) {
await visibleWebview?.controller.handleAuthCallback(token, provider)
@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from "uuid"
import { GrpcHandler } from "@/hosts/vscode/hostbridge-grpc-handler"
import { GrpcHandler } from "../host-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/hostbridge/client/host-grpc-client-base"
import { createGrpcClient } from "@hosts/vscode/client/host-grpc-client-base"
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
import * as host from "@shared/proto/index.host"
@@ -1,5 +1,5 @@
import { StreamingCallbacks } from "@/hosts/host-provider-types"
import { HostServiceHandlerConfig, hostServiceHandlers } from "@generated/hosts/vscode/hostbridge-grpc-service-config"
import { HostServiceHandlerConfig, hostServiceHandlers } from "./host-grpc-service-config"
import { GrpcRequestRegistry } from "@core/controller/grpc-request-registry"
/**
@@ -1,4 +1,4 @@
import { StreamingResponseHandler } from "./hostbridge-grpc-handler"
import { StreamingResponseHandler } from "./host-grpc-handler"
/**
* Generic type for service method handlers
@@ -1,5 +0,0 @@
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_ChangeType } from "@shared/proto/host/watch"
import { StreamingResponseHandler, getRequestRegistry } from "@/hosts/vscode/hostbridge-grpc-handler"
import { SubscribeToFileRequest, FileChangeEvent, FileChangeEvent_ChangeType } from "@shared/proto/host/watch"
import { StreamingResponseHandler, getRequestRegistry } from "../host-grpc-handler"
// Debounce configuration
const DEBOUNCE_DELAY = 100 // ms
@@ -1,24 +1,22 @@
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 } = { ...DEFAULT_OPTIONS, ...options }
const option = { modal, detail }
const { modal, detail, items } = options || {}
const option = items ? { modal, items } : { modal, detail }
let selectedOption: string | undefined = undefined
switch (type) {
case ShowMessageType.ERROR:
selectedOption = await window.showErrorMessage(message, option, ...items)
selectedOption = await window.showErrorMessage(message, option)
break
case ShowMessageType.WARNING:
selectedOption = await window.showWarningMessage(message, option, ...items)
selectedOption = await window.showWarningMessage(message, option)
break
default:
selectedOption = await window.showInformationMessage(message, option, ...items)
selectedOption = await window.showInformationMessage(message, option)
break
}
@@ -1,8 +1,8 @@
import { arePathsEqual } from "@/utils/path"
import * as path from "path"
import * as vscode from "vscode"
import { DecorationController } from "@integrations/editor/DecorationController"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "@integrations/editor/DiffViewProvider"
import { DecorationController } from "./DecorationController"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "./DiffViewProvider"
export class VscodeDiffViewProvider extends DiffViewProvider {
override async openDiffEditor(): Promise<void> {
@@ -82,6 +82,42 @@ 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
+7
View File
@@ -1,4 +1,5 @@
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"
@@ -50,6 +51,7 @@ 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
@@ -156,6 +158,10 @@ export class AuthService {
this._setProvider(providerName)
}
get authNonce(): string {
return this._authNonce
}
async getAuthToken(): Promise<string | null> {
if (!this._clineAuthInfo) {
return null
@@ -214,6 +220,7 @@ 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()
@@ -29,11 +29,6 @@ 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([
@@ -88,8 +83,6 @@ class TelemetryService {
BROWSER_ERROR: "task.browser_error",
// Tracks Gemini API specific performance metrics
GEMINI_API_PERFORMANCE: "task.gemini_api_performance",
// Tracks when API providers return errors
PROVIDER_API_ERROR: "task.provider_api_error",
// Collection of all task events
TASK_COLLECTION: "task.collection",
},
@@ -727,38 +720,6 @@ class TelemetryService {
)
}
/**
* 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
},
collect: boolean = true,
) {
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(),
},
},
collect,
)
}
/**
* Checks if telemetry is enabled
* @returns Boolean indicating whether telemetry is enabled
-93
View File
@@ -28,7 +28,6 @@ export type ApiProvider =
| "sambanova"
| "cerebras"
| "sapaicore"
| "groq"
export interface ApiHandlerOptions {
apiModelId?: string
@@ -100,9 +99,6 @@ export interface ApiHandlerOptions {
reasoningEffort?: string
sambanovaApiKey?: string
cerebrasApiKey?: string
groqApiKey?: string
groqModelId?: string
groqModelInfo?: ModelInfo
requestTimeoutMs?: number
sapAiCoreClientId?: string
sapAiCoreClientSecret?: string
@@ -2407,95 +2403,6 @@ 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"
@@ -236,8 +236,6 @@ 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":
@@ -300,8 +298,6 @@ 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:
@@ -382,9 +378,6 @@ 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 || [],
@@ -468,9 +461,6 @@ 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,
+4 -14
View File
@@ -2,27 +2,17 @@ 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
}
const response = await getHostBridgeProvider().diffClient.openDiff({
path: this.absolutePath,
content: this.originalContent ?? "",
})
this.activeDiffEditorId = response.diffId
getHostBridgeProvider().diffClient.openDiff({ path: this.absolutePath, content: this.originalContent ?? "" })
}
override async replaceText(
override replaceText(
content: string,
rangeToReplace: { startLine: number; endLine: number },
_currentLine: number,
currentLine: number,
): Promise<void> {
await getHostBridgeProvider().diffClient.replaceText({
diffId: this.activeDiffEditorId,
content: content,
startLine: rangeToReplace.startLine,
endLine: rangeToReplace.endLine,
})
throw new Error("Method not implemented.")
}
}
-29
View File
@@ -1,29 +0,0 @@
# 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
@@ -1,57 +0,0 @@
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
@@ -1,49 +0,0 @@
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()
})
@@ -1,2 +0,0 @@
node_modules
.vscode
@@ -1,3 +0,0 @@
# Test Workspace
This workspace is used for testing the extension in a controlled environment.
@@ -1,10 +0,0 @@
<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
@@ -1,44 +0,0 @@
/**
* 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
@@ -1,187 +0,0 @@
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
@@ -1,22 +0,0 @@
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
@@ -1,30 +0,0 @@
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", "src/test/e2e/**/*"]
"exclude": ["node_modules", ".vscode-test", "webview-ui"]
}
+1 -1
View File
@@ -14,5 +14,5 @@
"rootDir": "."
},
"include": ["src/**/*.test.ts"],
"exclude": ["src/test/**/*.js", "src/**/__tests__/*", "src/test/e2e/**/*.test.ts"]
"exclude": ["src/test/**/*.js", "src/**/__tests__/*"]
}
@@ -1,11 +1,4 @@
import {
VSCodeButton,
VSCodeDivider,
VSCodeLink,
VSCodeDropdown,
VSCodeOption,
VSCodeTag,
} from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton, VSCodeDivider, VSCodeLink, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { memo, useCallback, useEffect, useState, useRef } from "react"
import { useClineAuth } from "@/context/ClineAuthContext"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
@@ -102,15 +95,6 @@ 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()
@@ -237,28 +221,21 @@ export const ClineAccountView = () => {
<div className="text-sm text-[var(--vscode-descriptionForeground)]">{user.email}</div>
)}
<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>
{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>
</div>
</div>
@@ -427,10 +427,6 @@ 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,7 +1720,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
style={{
bottom: `calc(100vh - ${menuPosition}px + 6px)`,
}}>
<ApiOptions showModelOptions={true} modelIdErrorMessage={undefined} isPopup={true} />
<ApiOptions
showModelOptions={true}
apiErrorMessage={undefined}
modelIdErrorMessage={undefined}
isPopup={true}
/>
</ModelSelectorTooltip>
)}
</ModelContainer>
@@ -22,14 +22,10 @@ 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-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 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>
</div>
@@ -39,7 +35,7 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
width: "100%",
marginBottom: "8px",
}}>
<span className="codicon codicon-credit-card mr-[6px] text-[14px]" />
<span className="codicon codicon-credit-card mr-0.5 text-sm" />
Buy Credits
</VSCodeButtonLink>
@@ -1,12 +1,14 @@
import { useExtensionState } from "@/context/ExtensionStateContext"
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 { ModelsServiceClient } from "@/services/grpc-client"
import { StringRequest } from "@shared/proto/common"
import { 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"
@@ -35,10 +37,8 @@ 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, showSubmitButton }: ApiOptionsProps) => {
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, isPopup }: ApiOptionsProps) => {
// Use full context state for immediate save payload
const { apiConfiguration, uriScheme } = useExtensionState()
@@ -79,14 +79,6 @@ 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") {
@@ -130,9 +122,7 @@ 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",
@@ -142,13 +132,12 @@ 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-native">OpenAI</VSCodeOption>
<VSCodeOption value="openai">OpenAI Compatible</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>
@@ -242,9 +231,6 @@ 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} />
)}
@@ -297,12 +283,6 @@ 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>
)
}
@@ -1,260 +0,0 @@
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,7 +33,6 @@ 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>
@@ -1,33 +0,0 @@
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,8 +44,6 @@ import {
sapAiCoreDefaultModelId,
claudeCodeDefaultModelId,
claudeCodeModels,
groqModels,
groqDefaultModelId,
} from "@shared/api"
/**
@@ -180,14 +178,6 @@ 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, uriScheme } = useExtensionState()
const { apiConfiguration } = useExtensionState()
/**
* Updates a single field in the API configuration.
*
@@ -17,24 +17,21 @@ export const useApiConfigurationHandlers = () => {
* @param field - The field key to update
* @param value - The new value for the field
*/
const handleFieldChange = useCallback(
<K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => {
const updatedConfig = {
...apiConfiguration,
[field]: value,
}
const handleFieldChange = <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)
})
},
[apiConfiguration],
)
const protoConfig = convertApiConfigurationToProto(updatedConfig)
ModelsServiceClient.updateApiConfigurationProto(
UpdateApiConfigurationRequest.create({
apiConfiguration: protoConfig,
}),
).catch((error) => {
console.error(`Failed to update API configuration field ${field}:`, error)
})
}
/**
* Updates multiple fields in the API configuration at once.
@@ -45,24 +42,21 @@ export const useApiConfigurationHandlers = () => {
*
* @param updates - An object containing the fields to update and their new values
*/
const handleFieldsChange = useCallback(
(updates: Partial<ApiConfiguration>) => {
const updatedConfig = {
...apiConfiguration,
...updates,
}
const handleFieldsChange = (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)
})
},
[apiConfiguration],
)
const protoConfig = convertApiConfigurationToProto(updatedConfig)
ModelsServiceClient.updateApiConfigurationProto(
UpdateApiConfigurationRequest.create({
apiConfiguration: protoConfig,
}),
).catch((error) => {
console.error("Failed to update API configuration fields:", error)
})
}
return { handleFieldChange, handleFieldsChange, uriScheme, apiConfiguration }
return { handleFieldChange, handleFieldsChange }
}
@@ -1,19 +1,37 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { useState, memo } from "react"
import { useEffect, useState, memo } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { validateApiConfiguration } from "@/utils/validate"
import ApiOptions from "@/components/settings/ApiOptions"
import ClineLogoWhite from "@/assets/ClineLogoWhite"
import { AccountServiceClient } from "@/services/grpc-client"
import { EmptyRequest } from "@shared/proto/common"
import { AccountServiceClient, ModelsServiceClient, StateServiceClient } from "@/services/grpc-client"
import { EmptyRequest, BooleanRequest } 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">
@@ -49,7 +67,16 @@ const WelcomeView = memo(() => {
</VSCodeButton>
)}
<div className="mt-4.5">{showApiOptions && <ApiOptions showModelOptions={false} showSubmitButton={true} />}</div>
<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>
</div>
)
@@ -24,8 +24,6 @@ import {
openRouterDefaultModelInfo,
requestyDefaultModelId,
requestyDefaultModelInfo,
groqDefaultModelId,
groqModels,
} from "../../../src/shared/api"
import { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/shared/mcp"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
@@ -40,7 +38,6 @@ interface ExtensionStateContextType extends ExtensionState {
openRouterModels: Record<string, ModelInfo>
openAiModels: string[]
requestyModels: Record<string, ModelInfo>
groqModels: Record<string, ModelInfo>
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
filePaths: string[]
@@ -61,7 +58,6 @@ 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
@@ -209,9 +205,6 @@ 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: [] })
@@ -256,6 +249,7 @@ 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
@@ -637,7 +631,6 @@ export const ExtensionStateContextProvider: React.FC<{
openRouterModels,
openAiModels,
requestyModels,
groqModels: groqModelsState,
mcpServers,
mcpMarketplaceCatalog,
filePaths,
@@ -677,7 +670,6 @@ 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,