Compare commits

..
Author SHA1 Message Date
arafatkatze 27c5205db8 Adding console logs 2025-07-09 16:36:54 -06:00
0ec447c992 v3.18.9 Release Notes (#4783)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.18.9 with improved descriptions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-07-09 14:44:25 -07:00
pashpashpash ba41131b36 Pashpashpash/enabling streaming cline (#4784)
* grok 4

* fixing streaming in cline provider

* removing grok promo from cline provider

* changeset

* oopsie

* oopsie
2025-07-09 14:33:55 -07:00
Beeandabeatrix 0042230acd Handle authentication errors for Cline provider (#4781)
* Fix: Cline authentication errors

This commit improves error handling for Cline authentication. It adds checks for the presence of a Cline account authentication token before making API requests. If the token is missing, it throws an "Unauthorized" error, prompting the user to sign in. Additionally, it catches `ERR_BAD_REQUEST` or 401 errors from the Cline API and throws the same "Unauthorized" error, providing a more user-friendly experience when authentication fails.

* add changeset

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
2025-07-10 02:15:27 +05:30
ncrypted | Oliver 9ff705ffc0 Remove: Pricing for SAP AI Core Provider (#4778)
* remove: pricing for sapaicore

* add: model description hint regarding capacity unit pricing
2025-07-09 10:38:16 -07:00
Dennise Bartlett dea016408c Update Changelog and Versions (#4751) 2025-07-08 20:13:37 -07:00
Frostbourne bf82444aec grok 3 cache pricing (#4750) 2025-07-08 19:59:17 -07:00
988b65f1ad v3.18.7 Release Notes
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.18.7

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-07-08 18:41:16 -07:00
Dennise Bartlettandfrostbournesb d838bcdc34 Fix account buttons (#4742)
* Update links for account buttons

* add dashboard url

* setup links for future changes

---------

Co-authored-by: frostbournesb <frostbournesb@protonmail.com>
2025-07-08 17:56:38 -07:00
pashpashpash ff1e3297a8 ending grok promo in UI (#4740)
* ending grok promo in UI

* changeset
2025-07-08 17:30:06 -07:00
github-actions[bot] 2428389620 v3.18.6 Release Notes
v3.18.6 Release Notes
2025-07-08 16:16:52 -07:00
6f8627bb5f Organization Accounts added to Extension. Refactor Auth components for Accounts
* Working organization and personal inference, account switching, and usage/credit reporting.

* Organization dropdown tweaks (#4710)

* organization dropdown tweaks

* reverted AuthService

* Update Firebase Provider and Auth Service to support re-hydration of the user credentials

* Add Github Auth Flow

* Fix merge conflicts

* Fix some dumb typing

* recreate dropdown value when initialized (#4716)

* added changeset

* Update urls to production and handle some PR concerns

* Get user credits when signing in (#4736)

* get user balance when signing in instead of when there is no active org

* swapped order of getUserCredits, made calls async

* async changes continued, moved setIsLoading to finally

---------

Co-authored-by: canvrno <46584286+canvrno@users.noreply.github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-07-08 15:59:08 -07:00
Beeandabeatrix 1e81d98abf Fix fresh install mode launch config (#4732)
* Fix fresh install mode launch config

Updates the launch configuration in `.vscode/launch.json` to include a temporary profile and user data directory. This fixes the issue where the launch config does not start in fresh install mode for extension development. This change prevents  interference from existing settings and extensions. The `--user-data-dir=/tmp/cline/user` argument specifies a temporary directory for user data, while `--profile-temp` ensures a clean profile is used for each launch. Also, `--sync=off` is added to disable settings sync.

* update name

* tmp dir

* Implement in-memory storage for temporary profiles

Adds in-memory storage for global state, workspace state, and secrets when running in a temporary profile. This is determined by the `TEMP_PROFILE` environment variable being set to "true". When active, the `updateGlobalState`, `getGlobalState`, `updateGlobalStateBatch`, `updateSecretsBatch`, `storeSecret`, `getSecret`, `updateWorkspaceState`, and `getWorkspaceState` functions will use `Map` objects to store and retrieve data instead of VS Code's `globalState`, `secrets`, and `workspaceState` APIs. This ensures that no data is persisted to disk when using a temporary profile, providing a clean environment for testing and development.

* Refactor tmp user directory for dev launch config

This commit refactors the temporary user directory used in the development launch configuration.

- Updates `.vscode/launch.json` to use `${workspaceFolder}/dist/tmp/user` for the `--user-data-dir` argument, ensuring the temporary profile is located within the workspace.
- Adds `TEMP_PROFILE: "true"` to the environment variables in `.vscode/launch.json` to enable in-memory storage for temporary profiles.
- Renames the `clean-sandbox` task in `.vscode/tasks.json` to `clean-tmp-user` and modifies its command to remove and recreate the `${workspaceFolder}/dist/tmp/user` directory. This ensures a clean environment for each launch.

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
2025-07-08 15:05:11 -07:00
Sarah Fortune 267170920a Update the packaging script for the standalone app to include the same files as the extension in the zip (#4708)
* Package the same files for the standalone app and the extension

When packaging the standalone app use the same .vscodeignore that the vscode packager uses to decide which files to include.
Exclude extra files from the vscode extension that aren't needed: dist-standalone, old_docs and eslint-rules.

* Ignore the whole directory, not just the contents.

* Don't package .DS_Store files

* Update comment
2025-07-08 11:16:49 -07:00
Sarah Fortune 386c78c114 Update the no-vscode-postmessage eslint rule to check for any of the vscode SDK calls that have been replaced (#4715)
* Update the no-vscode-postmessage eslint rule to check for all the vscode SDK calls that have been replaced.

Expand the rule to check for all of the vscode SDK calls that have been
switched to the host bridge or replaced with native functions.

* Remove redundant messages
2025-07-08 10:50:10 -07:00
Sarah Fortune 265a56391a Replace vscode.workspace.fs.stat with fs.stat (#4714)
Use the util function that already exists isDirectory from utils/fs.ts
Use the same function in getRelativePaths
2025-07-08 10:49:56 -07:00
canvrno ff4bab22fb host bridge migration - openExternal (#4502) 2025-07-08 10:47:12 -07:00
tjandy98 bc468707a6 Add header for SAP AI Core Tracking (#4696)
* Add support for Gemini 2.5 Pro and Flash models

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

* Create calm-ads-glow.md

* Update request header

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

Add ai-client-type header for tracking

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

Create little-lions-joke.md

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

update changeset

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

update

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

---------

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>
2025-07-07 21:09:41 -07:00
Sarah Fortuneandellipsis-dev[bot] 0a6a565d41 Replace vscode.workspace.asRelativePath with the host bridge. (#4712)
* Replace vscode.workspace.asRelativePath with the host bridge.

Add a util function asRelativePath to path.ts that does the same thing as the vcode API (returns the path relative to the workspace directory).
In the getRelativePaths protobus handler, don't allow @mentions for files outside the workspace, they do not work in cline, so just prevent them from being added at all.
If the fs.stat fails for a file, don't @mention it either, if stat() fails it means the file doesn't exist or is unreadable.

* Update src/core/controller/file/getRelativePaths.ts

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

* Update src/core/controller/file/getRelativePaths.ts

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

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-07 19:40:14 -07:00
d30e4d0194 v3.18.5 Release Notes (#4704)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.18.5

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Cline Evaluation <cline@example.com>
2025-07-07 19:12:41 -07:00
AraandCline Evaluation d453eed582 feat: Globally persist plan/act mode across sessions and ensure proper workspace level persistance of chatsettings(for language) (#4646)
* feat: Persist plan/act mode across sessions

- Add mode persistence to global state storage
- Load saved mode on controller initialization
- Update state keys to include 'mode' as a valid global state key
- Ensures user's selected mode (plan/act) is maintained between VS Code sessions

* Split chat settings storage between global and workspace state
- Move mode setting to global state for cross-workspace persistence
- Store other chat settings in workspace state for project-specific configuration
- Update state retrieval logic to merge global mode with workspace settings
- Add chatSettings to LocalStateKey type definitions

* Code Cleanup

* Code Cleanup

* Get the chatsettings back to global

* Get the chatsettings back to global

* Get the chatsettings back to global

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-07-07 18:23:41 -07:00
Ara 7e32314c0b Optimize provider switching performance with batched storage operations (#4676)
* Optimize provider switching performance with batched storage operations

* Optimize provider switching performance with batched storage operations
2025-07-07 18:20:55 -07:00
Ara cce8f09ae5 Fixing bug on accepting tool response (#4675) 2025-07-07 18:19:50 -07:00
BeeandBeatrix Woo 0262e13ac4 Capture token usage for conversation turns in telementry (#4703)
Adds capture of token usage data for conversation turns in the telemetry service. This includes tokensIn, tokensOut, cacheWriteTokens, cacheReadTokens, and totalCost.

The changes involve:

- Modifying the `captureConversationTurnEvent` method in `TelemetryService.ts` to accept and include token usage data in the captured event properties.
- Updating the `Task` class in `src/core/task/index.ts` to pass token usage information when capturing conversation turn events. This ensures that token usage is tracked for both regular and cached responses.
- Refactor capture event to use object destructuring for easier readability

Co-authored-by: Beatrix Woo <beatrix@cline.bot>
2025-07-08 04:54:06 +05:30
celestial-vault 6d2cf55fc5 migrate download MCP response to proto (#4682) 2025-07-07 14:58:49 -07:00
Sarah Fortune 3577c2efa9 Remove uri service from the host bridge. (#4660)
It is being replaced with the npm module vscode-uri.
2025-07-07 14:27:19 -07:00
Sarah Fortune f97ef745d9 Replace vscode.workspace.getWorkspaceFolder() with the host bridge (#4659)
* Replace vscode.workspace.getWorkspaceFolder() with the host bridge

Use the hostbridge getWorkspacePaths() and use the result to
check for the workspaceFolder of the current file open in the IDE.

* Organize imports

* Update isLocatedInWorkspace() to check all the workspace directories, not just the first.

Add utility function to check if a path is inside a directory instead of duplicating the logic.

* Remove stubs for workspaceFolders that are not needed anymore.
2025-07-07 14:23:26 -07:00
schardosin 4e27e06670 SAP AI Core small bug fix and reorder models (#4686)
* small fix to avoid exception and removed log of returned data

* reorganized sapaicore models to be grouped in logical groups

* updated changese with changes in SAP AI Core

* removed additional received data log sections
2025-07-07 15:44:27 -05:00
ef02d6b0b2 Changeset version bump (#4685)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update package.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-07-07 13:26:11 -07:00
celestial-vaultandSaoud Rizwan 7ab6189595 mark welcomeViewCompleted as true after auth redirect (#4699)
* mark welcomeViewCompleted as true after auth redirect

* Create real-ravens-nail.md

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-07-07 13:14:48 -07:00
celestial-vault 4beaa2a086 sync local search state after timeout (#4698) 2025-07-07 11:16:08 -07:00
Ara 5f90018ab5 Revert "remove blur (#4664)" (#4677) 2025-07-06 12:05:30 -06:00
celestial-vault 9e761cd1f0 lazily initialize provider sdks with error catching (#4661) 2025-07-06 11:41:16 -05:00
126 changed files with 3359 additions and 1550 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add support for Gemini 2.5 Pro and Flash to SAP AI Core Provider
+1
View File
@@ -22,6 +22,7 @@
"react-hooks/exhaustive-deps": "off",
"eslint-rules/no-protobuf-object-literals": "error",
"eslint-rules/no-grpc-client-object-literals": "error",
"eslint-rules/no-direct-vscode-api": "warn",
"no-restricted-syntax": [
"error",
{
+4 -3
View File
@@ -23,19 +23,20 @@
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
"--profile-temp",
"--sync",
"off",
"--sync=off",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}"
],
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "clean-sandbox",
"preLaunchTask": "clean-tmp-user",
"internalConsoleOptions": "openOnSessionStart",
"postDebugTask": "stop",
"env": {
"IS_DEV": "true",
"TEMP_PROFILE": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
}
},
+2 -2
View File
@@ -233,10 +233,10 @@
"type": "shell"
},
{
"label": "clean-sandbox",
"label": "clean-tmp-user",
"type": "shell",
"dependsOn": ["watch"],
"command": "rm -rf .vscode-dev"
"command": "rm -rf ${workspaceFolder}/dist/tmp/user && mkdir -p ${workspaceFolder}/dist/tmp/user"
}
],
"inputs": [
+4
View File
@@ -2,8 +2,10 @@
.vscode/**
.vscode-test/**
out/**
dist-standalone/**
node_modules/**
src/**
standalone/**
.gitignore
.yarnrc
esbuild.js
@@ -13,6 +15,7 @@ vsc-extension-quickstart.md
**/*.map
**/*.ts
**/.vscode-test.*
eslint-rules/**
# Custom
demo.gif
@@ -32,6 +35,7 @@ webview-ui/node_modules/**
# Ignore docs
docs/**
old_docs/**
# Fix issue where codicons don't get packaged (https://github.com/microsoft/vscode-extension-samples/issues/692)
!node_modules/@vscode/codicons/dist/codicon.css
+104
View File
@@ -0,0 +1,104 @@
# Account Toggle UI Improvements
## Overview
This document outlines the improvements made to the account switching toggle in the Cline extension's account view. The previous dropdown implementation has been replaced with a modern, accessible segmented toggle component.
## Problem Statement
The original account switching UI used a VSCode dropdown component that appeared "leaky" or "weird" and didn't integrate well with the overall account card design. The dropdown styling was inconsistent with the rest of the interface and provided a suboptimal user experience.
## Solution Implemented
### 1. Custom SegmentedToggle Component
Created a new reusable `SegmentedToggle` component (`webview-ui/src/components/common/SegmentedToggle.tsx`) with the following features:
#### Key Features:
- **Modern Design**: Pill-style segmented control similar to iOS design patterns
- **Smooth Animations**: Sliding indicator with smooth transitions between selections
- **VSCode Theme Integration**: Uses VSCode CSS variables for consistent theming
- **Accessibility**: Proper ARIA attributes and keyboard navigation support
- **Responsive**: Adapts to different screen sizes and content lengths
- **Reusable**: Generic component that can be used throughout the application
#### Technical Implementation:
- Uses React hooks (`useState`, `useEffect`, `useRef`) for state management
- Implements a sliding indicator that smoothly animates between options
- Calculates indicator position dynamically based on active button dimensions
- Handles edge cases like component mounting and option changes
- Provides proper TypeScript interfaces for type safety
### 2. AccountView Integration
Updated the `AccountView` component (`webview-ui/src/components/account/AccountView.tsx`) to:
- Replace `VSCodeDropdown` with the new `SegmentedToggle`
- Maintain all existing functionality for organization switching
- Improve visual integration with the account card design
- Provide better user experience for account context switching
### 3. Styling and Theme Integration
The component uses VSCode's CSS custom properties for consistent theming:
- `--vscode-input-background`: Background color for the toggle container
- `--vscode-input-border`: Border color for the toggle container
- `--vscode-button-background`: Background color for the active indicator
- `--vscode-button-foreground`: Text color for active selection
- `--vscode-foreground`: Text color for inactive options
- `--vscode-focusBorder`: Focus ring color for accessibility
## Benefits
### User Experience Improvements:
1. **Better Visual Integration**: The toggle seamlessly integrates with the account card design
2. **Clearer State Indication**: Active selection is immediately obvious with the sliding indicator
3. **Smooth Interactions**: Animated transitions provide satisfying feedback
4. **Modern Feel**: Contemporary design that feels native to modern applications
### Technical Improvements:
1. **Reusability**: Component can be used in other parts of the application
2. **Accessibility**: Proper ARIA attributes and keyboard support
3. **Type Safety**: Full TypeScript support with proper interfaces
4. **Performance**: Efficient rendering with minimal re-renders
5. **Maintainability**: Clean, well-documented code structure
## Usage Example
```tsx
<SegmentedToggle
options={[
{ value: "", label: "Personal" },
{ value: "org1", label: "Organization" },
]}
value={activeValue}
onChange={handleChange}
className="w-full"
/>
```
## Files Modified
1. **Created**: `webview-ui/src/components/common/SegmentedToggle.tsx`
- New reusable segmented toggle component
2. **Created**: `webview-ui/src/components/common/SegmentedToggleDemo.tsx`
- Demo component for testing and development
3. **Modified**: `webview-ui/src/components/account/AccountView.tsx`
- Replaced VSCodeDropdown with SegmentedToggle
- Updated import statements and handler logic
- Removed unused type definitions
## Testing
- All builds pass successfully
- TypeScript compilation without errors
- Component renders correctly in different states
- Smooth animations and proper accessibility support
## Future Enhancements
The SegmentedToggle component is designed to be extensible and could support:
- Custom styling props
- Icon support alongside labels
- Vertical orientation
- Different animation styles
- Size variants (small, medium, large)
## Conclusion
The new SegmentedToggle component provides a significant improvement over the previous dropdown implementation, offering better visual integration, improved user experience, and a more modern interface that aligns with contemporary design patterns while maintaining full accessibility and VSCode theme compatibility.
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## [3.18.9]
- Fix streaming reliability issues with Cline provider that could cause connection problems during long conversations
- Fix authentication error handling for Cline provider to show clearer error messages when not signed in and prevent recursive failed requests
- Remove incorrect pricing display for SAP AI Core provider since it uses non-USD "Capacity Units" that cannot be directly converted (Thanks @ncryptedV1!)
## [3.18.8]
- Update pricing for Grok 3 model because the promotion ended
## [3.18.7]
- Remove promotional "free" messaging for Grok 3 model in UI
## [3.18.6]
- Update request header to include `"ai-client-type": "Cline"` to SAP Api Provider
- Add organization organization accounts
## [3.18.5]
- Fix Plan/Act mode persistence across sessions and multi-workspace conflicts
- Improve provider switching performance by 18x (from 550ms to 30ms) with batched storage operations
- Improve SAP AI Core provider model organization and fix exception handling (Thanks @schardosin!)
## [3.18.4]
- Add support for Gemini 2.5 Pro and Flash to SAP AI Core Provider
- Fix logging in with Cline account not getting past welcome screen
## [3.18.3]
- Improve Cerebras Qwen model performance by removing thinking tokens from model input (Thanks @kevint-cerebras!)
@@ -0,0 +1,123 @@
const { RuleTester: DirectApiRuleTester } = require("eslint")
const noDirectVscodeApiRule = require("../no-direct-vscode-api")
const directApiRuleTester = new DirectApiRuleTester({
parser: require.resolve("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
})
directApiRuleTester.run("no-direct-vscode-api", noDirectVscodeApiRule, {
valid: [
// Should allow vscode.postMessage in grpc-client-base.ts
{
code: `vscode.postMessage({ type: "grpc_request", data: {} })`,
filename: "grpc-client-base.ts",
},
{
code: `vscode.postMessage({ type: "grpc_request_cancel" })`,
filename: "/path/to/grpc-client-base.ts",
},
// Should allow in exception directories
{
code: `vscode.workspace.workspaceFolders`,
filename: "/src/hosts/vscode/host-bridge.ts",
},
{
code: `vscode.workspace.fs.stat(uri)`,
filename: "/standalone/runtime-files/helpers.ts",
},
// Should allow other vscode API calls
{
code: `vscode.window.showInformationMessage("Hello")`,
filename: "test.ts",
},
// Should allow postMessage calls on other objects
{
code: `window.postMessage({ type: "test" }, "*")`,
filename: "test.ts",
},
// Should allow variables named vscode but not calling postMessage
{
code: `const vscode = { other: "method" }; vscode.other()`,
filename: "test.ts",
},
],
invalid: [
// Should disallow vscode.postMessage in regular files
{
code: `vscode.postMessage({ type: "test", data: {} })`,
filename: "test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should disallow vscode.postMessage in components
{
code: `vscode.postMessage({ type: "apiConfiguration", apiConfiguration })`,
filename: "ApiOptions.tsx",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should disallow vscode.postMessage in test files
{
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
filename: "test.test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should disallow property access for disallowed APIs
{
code: `const folders = vscode.workspace.workspaceFolders;`,
filename: "workspace.ts",
errors: [
{
messageId: "useHostBridge",
},
],
},
// Should disallow method calls for disallowed APIs
{
code: `const relativePath = vscode.workspace.asRelativePath(filePath);`,
filename: "path-utils.ts",
errors: [
{
messageId: "usePathUtils",
},
],
},
// Should disallow nested property access
{
code: `const stats = await vscode.workspace.fs.stat(uri);`,
filename: "file-utils.ts",
errors: [
{
messageId: "useFsUtils",
},
],
},
// Should disallow getting a workspace folder
{
code: `const folder = vscode.workspace.getWorkspaceFolder(uri);`,
filename: "path-helper.ts",
errors: [
{
messageId: "usePathUtils",
},
],
},
],
})
@@ -1,74 +0,0 @@
const { RuleTester: VscodeRuleTester } = require("eslint")
const vscodePostmessageRule = require("../no-vscode-postmessage")
const vscodeRuleTester = new VscodeRuleTester({
parser: require.resolve("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
})
vscodeRuleTester.run("no-vscode-postmessage", vscodePostmessageRule, {
valid: [
// Should allow vscode.postMessage in grpc-client-base.ts
{
code: `vscode.postMessage({ type: "grpc_request", data: {} })`,
filename: "grpc-client-base.ts",
},
{
code: `vscode.postMessage({ type: "grpc_request_cancel" })`,
filename: "/path/to/grpc-client-base.ts",
},
// Should allow other vscode API calls
{
code: `vscode.window.showInformationMessage("Hello")`,
filename: "test.ts",
},
// Should allow postMessage calls on other objects
{
code: `window.postMessage({ type: "test" }, "*")`,
filename: "test.ts",
},
// Should allow variables named vscode but not calling postMessage
{
code: `const vscode = { other: "method" }; vscode.other()`,
filename: "test.ts",
},
],
invalid: [
// Should ban vscode.postMessage in regular files
{
code: `vscode.postMessage({ type: "test", data: {} })`,
filename: "test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should ban vscode.postMessage in components
{
code: `vscode.postMessage({ type: "apiConfiguration", apiConfiguration })`,
filename: "ApiOptions.tsx",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should ban vscode.postMessage in test files
{
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
filename: "test.test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
],
})
+3 -3
View File
@@ -1,13 +1,13 @@
// eslint-rules/index.js
const noProtobufObjectLiterals = require("./no-protobuf-object-literals")
const noGrpcClientObjectLiterals = require("./no-grpc-client-object-literals")
const noVscodePostmessage = require("./no-vscode-postmessage")
const noDirectVscodeApi = require("./no-direct-vscode-api")
module.exports = {
rules: {
"no-protobuf-object-literals": noProtobufObjectLiterals,
"no-grpc-client-object-literals": noGrpcClientObjectLiterals,
"no-vscode-postmessage": noVscodePostmessage,
"no-direct-vscode-api": noDirectVscodeApi,
},
configs: {
recommended: {
@@ -15,7 +15,7 @@ module.exports = {
rules: {
"local/no-protobuf-object-literals": "error",
"local/no-grpc-client-object-literals": "error",
"local/no-vscode-postmessage": "error",
"local/no-direct-vscode-api": "warn",
},
},
},
+164
View File
@@ -0,0 +1,164 @@
const { ESLintUtils } = require("@typescript-eslint/utils")
const path = require("path")
const createRule = ESLintUtils.RuleCreator((name) => `https://cline.bot/eslint-rules/${name}`)
// Configuration of disallowed VSCode APIs and their recommended alternatives
const disallowedApis = {
"vscode.postMessage": {
messageId: "useGrpcClient",
},
"vscode.workspace.fs.stat": {
messageId: "useFsUtils",
},
"vscode.workspace.workspaceFolders": {
messageId: "useHostBridge",
},
"vscode.workspace.asRelativePath": {
messageId: "usePathUtils",
},
"vscode.workspace.getWorkspaceFolder": {
messageId: "usePathUtils",
},
}
module.exports = createRule({
name: "no-direct-vscode-api",
meta: {
type: "problem",
docs: {
description:
"Disallow direct VSCode API usage in favor of Cline's abstraction layers, except in src/hosts/vscode and standalone/runtime-files directories",
recommended: "error",
},
messages: {
useGrpcClient:
"Use gRPC service clients instead of vscode.postMessage().\n" +
"Example: AccountServiceClient.methodName(RequestType.create({...})) instead of vscode.postMessage({type: '...'}).\n" +
"Found: {{code}}",
useFsUtils:
"Use utilities in @/utils/fs instead of vscode.workspace.fs.stat.\n" +
"Example: import { isDirectory } from '@/utils/fs' or use the file system methods from the host bridge provider.\n" +
"Found: {{code}}",
useHostBridge:
"Use getHostBridgeProvider().workspaceClient.getWorkspacePaths({}) instead of vscode.workspace.workspaceFolders.\n" +
"This provides a consistent abstraction across VSCode and standalone environments.\n" +
"Found: {{code}}",
usePathUtils:
"Use path utilities from @/utils/path instead of direct VSCode workspace path methods.\n" +
"This provides consistent path handling across different environments.\n" +
"Found: {{code}}",
},
schema: [],
},
defaultOptions: [],
create(context) {
// Check if current file is in an exception directory or is grpc-client-base.ts
const filename = context.filename
const isGrpcClientBase = path.basename(filename) === "grpc-client-base.ts"
// Skip checking files in src/hosts/vscode or standalone/runtime-files
const isExceptionDirectory = filename.includes("/src/hosts/vscode/") || filename.includes("/standalone/runtime-files/")
// Pattern for checking memberExpressions like vscode.workspace.fs.stat
function checkMemberExpression(node) {
// Skip if this file is in an exception directory or is grpc-client-base.ts
if (isGrpcClientBase || isExceptionDirectory) {
return
}
// For handling nested properties like vscode.workspace.fs.stat
function getFullPropertyPath(node) {
if (node.type !== "MemberExpression") {
return node.name || ""
}
const objectPart = getFullPropertyPath(node.object)
const propertyPart = node.property.name || ""
return objectPart ? `${objectPart}.${propertyPart}` : propertyPart
}
// Check if the expression matches one of our disallowed patterns
if (node.object && node.object.type === "Identifier" && node.object.name === "vscode") {
const fullPath = `vscode.${node.property.name}`
checkDisallowedApi(fullPath, node)
}
// Handle nested expressions like vscode.workspace.fs.stat
else if (node.object && node.object.type === "MemberExpression") {
const fullPath = getFullPropertyPath(node)
// Only proceed if it starts with vscode
if (fullPath.startsWith("vscode.")) {
checkDisallowedApi(fullPath, node)
}
}
}
// Check if an expression matches a disallowed API and report if it does
function checkDisallowedApi(expressionPath, node) {
// Check exact matches
if (disallowedApis[expressionPath]) {
reportViolation(expressionPath, node)
return
}
// Check prefix matches (for nested properties)
for (const disallowedApi in disallowedApis) {
// For direct property access like vscode.workspace.workspaceFolders
if (expressionPath === disallowedApi) {
reportViolation(disallowedApi, node)
return
}
// For method calls like vscode.workspace.asRelativePath(...)
if (expressionPath.startsWith(`${disallowedApi}.`) || expressionPath.startsWith(`${disallowedApi}(`)) {
reportViolation(disallowedApi, node)
return
}
}
}
// Report a violation with the appropriate message
function reportViolation(disallowedApi, node) {
const sourceCode = context.sourceCode
const config = disallowedApis[disallowedApi]
// For method calls, get the whole call expression
let reportNode = node
let parentNode = sourceCode.getAncestors(node).pop()
if (parentNode && parentNode.type === "CallExpression" && parentNode.callee === node) {
reportNode = parentNode
}
const callText = sourceCode.getText(reportNode).trim()
context.report({
node: reportNode,
messageId: config.messageId,
data: {
code: callText,
},
})
}
return {
// Detect basic member expressions (e.g., vscode.postMessage)
MemberExpression(node) {
checkMemberExpression(node)
},
// Detect property access through destructuring
VariableDeclarator(node) {
// Skip if this file is in an exception directory or is grpc-client-base.ts
if (isGrpcClientBase || isExceptionDirectory) {
return
}
// Destructuring pattern checks removed as developers don't use the API this way
// They always use direct imports: import * as vscode from "vscode" and direct access: vscode.thing.foo
},
}
},
})
-61
View File
@@ -1,61 +0,0 @@
const { ESLintUtils } = require("@typescript-eslint/utils")
const path = require("path")
const createRule = ESLintUtils.RuleCreator((name) => `https://cline.bot/eslint-rules/${name}`)
module.exports = createRule({
name: "no-vscode-postmessage",
meta: {
type: "problem",
docs: {
description: "Ban vscode.postMessage() calls in favor of gRPC service clients, except in grpc-client-base.ts",
recommended: "error",
},
messages: {
useGrpcClient:
"Use gRPC service clients instead of vscode.postMessage().\n" +
"Example: AccountServiceClient.methodName(RequestType.create({...})) instead of vscode.postMessage({type: '...'}).\n" +
"Found: {{code}}",
},
schema: [],
},
defaultOptions: [],
create(context) {
// Check if current file is grpc-client-base.ts (exception case)
const filename = context.filename
const isGrpcClientBase = path.basename(filename) === "grpc-client-base.ts"
return {
// Detect vscode.postMessage calls
"CallExpression[callee.type='MemberExpression']"(node) {
// Skip if this is grpc-client-base.ts
if (isGrpcClientBase) {
return
}
const callee = node.callee
// Check for vscode.postMessage pattern
if (
callee.object &&
callee.object.type === "Identifier" &&
callee.object.name === "vscode" &&
callee.property &&
callee.property.name === "postMessage"
) {
const sourceCode = context.sourceCode
const callText = sourceCode.getText(node).trim()
context.report({
node,
messageId: "useGrpcClient",
data: {
code: callText,
},
})
}
},
}
},
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.18.3",
"version": "3.18.9",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.18.3",
"version": "3.18.9",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.18.3",
"version": "3.18.9",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
+96 -43
View File
@@ -1,76 +1,129 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
// Service for account-related operations
service AccountService {
// Handles the user clicking the login link in the UI.
// Generates a secure nonce for state validation, stores it in secrets,
// and opens the authentication URL in the external browser.
rpc accountLoginClicked(EmptyRequest) returns (String);
// Handles the user clicking the login link in the UI.
// Generates a secure nonce for state validation, stores it in secrets,
// and opens the authentication URL in the external browser.
rpc accountLoginClicked(EmptyRequest) returns (String);
// Handles the user clicking the logout button in the UI.
// Clears API keys and user state.
rpc accountLogoutClicked(EmptyRequest) returns (Empty);
// Handles the user clicking the logout button in the UI.
// Clears API keys and user state.
rpc accountLogoutClicked(EmptyRequest) returns (Empty);
// Subscribe to auth callback events (when authentication tokens are received)
rpc subscribeToAuthCallback(EmptyRequest) returns (stream String);
// Subscribe to auth status update events (when authentication state changes)
rpc subscribeToAuthStatusUpdate(EmptyRequest)
returns (stream AuthState);
// Handles authentication state changes from the Firebase context.
// Updates the user info in global state and returns the updated value.
rpc authStateChanged(AuthStateChangedRequest) returns (AuthStateChanged);
// Handles authentication state changes from the Firebase context.
// Updates the user info in global state and returns the updated value.
rpc authStateChanged(AuthStateChangedRequest)
returns (AuthState);
// Fetches all user credits data (balance, usage transactions, payment transactions)
rpc fetchUserCreditsData(EmptyRequest) returns (UserCreditsData);
// Fetches all user credits data
// (balance, usage transactions, payment transactions)
rpc getUserCredits(EmptyRequest) returns (UserCreditsData);
rpc getOrganizationCredits(GetOrganizationCreditsRequest) returns (OrganizationCreditsData);
// Fetches all user organizations data
// Returns a list of UserOrganization objects
rpc getUserOrganizations(EmptyRequest) returns (UserOrganizationsResponse);
rpc setUserOrganization(UserOrganizationUpdateRequest) returns (Empty);
}
message AuthStateChangedRequest {
Metadata metadata = 1;
UserInfo user = 2;
Metadata metadata = 1;
UserInfo user = 2;
}
message AuthStateChanged {
optional UserInfo user = 1;
message AuthState {
optional UserInfo user = 1;
}
// User's information
message UserInfo {
optional string display_name = 1;
optional string email = 2;
optional string photo_url = 3;
string uid = 1;
optional string display_name = 2;
optional string email = 3;
optional string photo_url = 4;
}
message UserOrganization {
bool active = 1;
string member_id = 2;
string name = 3;
string organization_id = 4;
repeated string roles = 5; // ["admin", "member", "owner"]
}
message UserOrganizationsResponse {
repeated UserOrganization organizations = 1;
}
message UserOrganizationUpdateRequest {
optional string organization_id = 1;
}
// Response containing all user credits data
message UserCreditsData {
UserCreditsBalance balance = 1;
repeated UsageTransaction usage_transactions = 2;
repeated PaymentTransaction payment_transactions = 3;
UserCreditsBalance balance = 1;
repeated UsageTransaction usage_transactions = 2;
repeated PaymentTransaction payment_transactions = 3;
}
message GetOrganizationCreditsRequest {
string organization_id = 1;
}
message OrganizationCreditsData {
UserCreditsBalance balance = 1;
string organization_id = 2;
repeated OrganizationUsageTransaction usage_transactions = 3;
}
// User's current credit balance
message UserCreditsBalance {
double current_balance = 1;
double current_balance = 1;
}
// Usage transaction record
message UsageTransaction {
string spent_at = 1;
string creator_id = 2;
double credits = 3;
string model_provider = 4;
string model = 5;
int32 prompt_tokens = 6;
int32 completion_tokens = 7;
int32 total_tokens = 8;
string ai_inference_provider_name = 1;
string ai_model_name = 2;
string ai_model_type_name = 3;
int32 completion_tokens = 4;
double cost_usd = 5;
string created_at = 6;
double credits_used = 7;
string generation_id = 8;
string organization_id = 9;
int32 prompt_tokens = 10;
int32 total_tokens = 11;
string user_id = 12;
}
// Payment transaction record
message PaymentTransaction {
string paid_at = 1;
string creator_id = 2;
int32 amount_cents = 3;
double credits = 4;
string paid_at = 1;
string creator_id = 2;
int32 amount_cents = 3;
double credits = 4;
}
message OrganizationUsageTransaction {
string ai_inference_provider_name = 1;
string ai_model_name = 2;
string ai_model_type_name = 3;
int32 completion_tokens = 4;
double cost_usd = 5;
string created_at = 6;
double credits_used = 7;
string generation_id = 8;
string organization_id = 9;
int32 prompt_tokens = 10;
int32 total_tokens = 11;
string user_id = 12;
}
+1 -2
View File
@@ -1,11 +1,10 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
service BrowserService {
rpc getBrowserConnectionInfo(EmptyRequest) returns (BrowserConnectionInfo);
rpc testBrowserConnection(StringRequest) returns (BrowserConnection);
+1 -2
View File
@@ -1,11 +1,10 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
service CheckpointsService {
rpc checkpointDiff(Int64Request) returns (Empty);
rpc checkpointRestore(CheckpointRestoreRequest) returns (Empty);
+1 -2
View File
@@ -1,11 +1,10 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
// Service for file-related operations
service FileService {
// Copies text to clipboard
+3
View File
@@ -13,4 +13,7 @@ service EnvService {
// Reads text from the system clipboard.
rpc clipboardReadText(cline.EmptyRequest) returns (cline.String);
// Opens a URL in the user's default browser or application.
rpc openExternal(cline.StringRequest) returns (cline.Empty);
}
-36
View File
@@ -1,36 +0,0 @@
syntax = "proto3";
package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
import "common.proto";
// UriService provides methods for working with URIs in the IDE
service UriService {
// Create a new file URI from a file path
rpc file(cline.StringRequest) returns (Uri);
// Join a URI with additional path segments
rpc joinPath(JoinPathRequest) returns (Uri);
// Parse a string URI into a Uri object
rpc parse(cline.StringRequest) returns (Uri);
}
// Uri represents a URI in the IDE
message Uri {
string scheme = 1;
string authority = 2;
string path = 3;
string query = 4;
string fragment = 5;
string fs_path = 6;
}
// Request for joining path segments to a URI
message JoinPathRequest {
cline.Metadata metadata = 1;
Uri base = 2;
repeated string path_segments = 3;
}
+14 -3
View File
@@ -1,16 +1,15 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
service McpService {
rpc toggleMcpServer(ToggleMcpServerRequest) returns (McpServers);
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
rpc downloadMcp(StringRequest) returns (Empty);
rpc downloadMcp(StringRequest) returns (McpDownloadResponse);
rpc restartMcpServer(StringRequest) returns (McpServers);
rpc deleteMcpServer(StringRequest) returns (McpServers);
rpc toggleToolAutoApprove(ToggleToolAutoApproveRequest) returns (McpServers);
@@ -119,3 +118,15 @@ message McpMarketplaceItem {
message McpMarketplaceCatalog {
repeated McpMarketplaceItem items = 1;
}
message McpDownloadResponse {
string mcp_id = 1;
string github_url = 2;
string name = 3;
string author = 4;
string description = 5;
string readme_content = 6;
string llms_installation_content = 7;
bool requires_api_key = 8;
optional string error = 9;
}
+2 -3
View File
@@ -1,11 +1,10 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
// Service for model-related operations
service ModelsService {
// Fetches available models from Ollama
@@ -165,7 +164,7 @@ message ModelsApiConfiguration {
// From ApiHandlerOptions (excluding onRetryAttempt function)
optional string api_model_id = 1;
optional string api_key = 2;
optional string cline_api_key = 3;
optional string cline_account_id = 3;
optional string task_id = 4;
optional string lite_llm_base_url = 5;
optional string lite_llm_model_id = 6;
+1 -2
View File
@@ -1,11 +1,10 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
// SlashService provides methods for managing slash
service SlashService {
// Sends button click message
+2 -3
View File
@@ -1,10 +1,9 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
service StateService {
rpc getLatestState(EmptyRequest) returns (State);
rpc updateTerminalConnectionTimeout(Int64Request) returns (Int64);
@@ -126,7 +125,7 @@ message ApiConfiguration {
optional string api_base_url = 4;
// Provider-specific API keys
optional string cline_api_key = 5;
optional string cline_account_id = 5;
optional string openrouter_api_key = 6;
optional string anthropic_base_url = 7;
optional string openai_api_key = 8;
+1 -2
View File
@@ -1,11 +1,10 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
service TaskService {
// Cancels the currently running task
rpc cancelTask(EmptyRequest) returns (Empty);
+1 -2
View File
@@ -1,11 +1,10 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
// Enum for webview provider types
enum WebviewProviderType {
SIDEBAR = 0;
+1 -2
View File
@@ -1,11 +1,10 @@
syntax = "proto3";
package cline;
import "common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
service WebService {
rpc checkIsImageUrl(StringRequest) returns (IsImageUrl);
rpc fetchOpenGraphData(StringRequest) returns (OpenGraphData);
+87 -70
View File
@@ -1,83 +1,100 @@
import fs from "fs"
import path from "path"
import { glob } from "glob"
import archiver from "archiver"
import { cp } from "fs/promises"
import { execSync } from "child_process"
import fs from "fs"
import { cp } from "fs/promises"
import { glob } from "glob"
import ignore from "ignore"
import path from "path"
const BUILD_DIR = "dist-standalone"
const SOURCE_DIR = "standalone/runtime-files"
const RUNTIME_DEPS_DIR = "standalone/runtime-files"
await cp(SOURCE_DIR, BUILD_DIR, { recursive: true })
// Run npm install in the distribution directory
console.log("Running npm install in distribution directory...")
const cwd = process.cwd()
process.chdir(BUILD_DIR)
try {
execSync("npm install", { stdio: "inherit" })
// Move the vscode directory into node_modules.
// It can't be installed using npm because it will create a symlink which is not portable.
fs.renameSync("vscode", path.join("node_modules", "vscode"))
} catch (error) {
console.error("Error during setup:", error)
process.exit(1)
} finally {
process.chdir(cwd)
async function main() {
await installNodeDependencies()
await zipDistribution()
}
// Check for native .node modules.
const nativeModules = await glob("**/*.node", { cwd: BUILD_DIR, nodir: true })
if (nativeModules.length > 0) {
console.error("Native node modules cannot be included in the standalone distribution:\n", nativeModules.join("\n"))
process.exit(1)
async function installNodeDependencies() {
await cpr(RUNTIME_DEPS_DIR, BUILD_DIR)
console.log("Running npm install in distribution directory...")
const cwd = process.cwd()
process.chdir(BUILD_DIR)
try {
execSync("npm install", { stdio: "inherit" })
// Move the vscode directory into node_modules.
// It can't be installed using npm because it will create a symlink which cannot be unzipped correctly on windows.
fs.renameSync("vscode", path.join("node_modules", "vscode"))
} catch (error) {
console.error("Error during setup:", error)
process.exit(1)
} finally {
process.chdir(cwd)
}
// Check for native .node modules.
const nativeModules = await glob("**/*.node", { cwd: BUILD_DIR, nodir: true })
if (nativeModules.length > 0) {
console.error("Native node modules cannot be included in the standalone distribution:\n", nativeModules.join("\n"))
process.exit(1)
}
}
// Zip the build directory (excluding any pre-existing output zip).
const zipPath = path.join(BUILD_DIR, "standalone.zip")
const output = fs.createWriteStream(zipPath)
const archive = archiver("zip", { zlib: { level: 3 } })
async function zipDistribution() {
// Zip the build directory (excluding any pre-existing output zip).
const zipPath = path.join(BUILD_DIR, "standalone.zip")
const output = fs.createWriteStream(zipPath)
const archive = archiver("zip", { zlib: { level: 3 } })
// Use the same ignore file that vscode uses when packaging the extension.
const vscodeignore = ignore().add(fs.readFileSync(".vscodeignore", "utf8"))
output.on("close", () => {
console.log(`Created ${zipPath} (${(archive.pointer() / 1024 / 1024).toFixed(1)} MB)`)
})
archive.on("warning", (err) => {
console.warn(`Warning: ${err}`)
})
archive.on("error", (err) => {
throw err
})
output.on("close", () => {
console.log(`Created ${zipPath} (${(archive.pointer() / 1024 / 1024).toFixed(1)} MB)`)
})
archive.on("warning", (err) => {
console.warn(`Warning: ${err}`)
})
archive.on("error", (err) => {
throw err
})
archive.pipe(output)
archive.glob("**/*", {
cwd: BUILD_DIR,
ignore: ["standalone.zip"],
})
archive.pipe(output)
// Add all the files from the standalone build dir.
archive.glob("**/*", {
cwd: BUILD_DIR,
ignore: ["standalone.zip"],
})
// Add the whole cline directory under "extension"
archive.directory(process.cwd(), "extension", (entry) => {
// Skip certain directories.
const exclude = [
BUILD_DIR + "/",
"node_modules/", // node_modules nearly 1GB.
"webview-ui/node_modules/", // node_modules nearly 1GB.
]
// These node modules are used at runtime as assets, they need to be included.
const include = ["node_modules/@vscode/", "webview-ui/node_modules/katex"]
const name = entry.name
if (include.some((prefix) => name.startsWith(prefix))) {
// Add the whole cline directory under "extension"
archive.directory(process.cwd(), "extension", (entry) => {
if (entry.name.startsWith(".git")) {
return false
}
if (entry.name.endsWith(".DS_Store")) {
return false
}
if (entry.name === "dist" || entry.name.startsWith("dist" + path.sep)) {
// Don't include the vscode extension build dir.
return false
}
if (vscodeignore.ignores(entry.name)) {
// Exclude entries also ignored by the vscode packager.
return false
}
return entry
}
if (exclude.some((prefix) => name.startsWith(prefix))) {
return false
}
if (name.match(/(^|\/)\./)) {
// exclude dot directories
return false
}
return entry
})
})
console.log("Zipping package...")
await archive.finalize()
console.log("Zipping package...")
await archive.finalize()
}
/* cp -r */
async function cpr(source, dest) {
await cp(source, dest, {
recursive: true,
preserveTimestamps: true,
dereference: false, // preserve symlinks instead of following them
})
}
await main()
+6 -3
View File
@@ -45,8 +45,10 @@ describe("OllamaHandler", () => {
this.skip()
}
this.timeout(5000)
// Ensure client is initialized
const client = (handler as any).ensureClient()
// Mock the Ollama client's chat method
const chatStub = sinon.stub(handler["client"], "chat").resolves({
const chatStub = sinon.stub(client, "chat").resolves({
[Symbol.asyncIterator]: async function* () {
yield {
message: { content: "Hello, world!" },
@@ -139,8 +141,9 @@ describe("OllamaHandler", () => {
// Restore real timers for this test
clock.restore()
// Mock the Ollama client's chat method to fail on first call and succeed on second
const chatStub = sinon.stub(handler["client"], "chat")
// Ensure client is initialized and mock the Ollama client's chat method to fail on first call and succeed on second
const client = (handler as any).ensureClient()
const chatStub = sinon.stub(client, "chat")
// First call throws an error
chatStub.onFirstCall().rejects(new Error("API Error"))
+22 -7
View File
@@ -7,18 +7,33 @@ import { ApiStream } from "../transform/stream"
export class AnthropicHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Anthropic
private client: Anthropic | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Anthropic({
apiKey: this.options.apiKey,
baseURL: this.options.anthropicBaseUrl || undefined,
})
}
private ensureClient(): Anthropic {
if (!this.client) {
if (!this.options.apiKey) {
throw new Error("Anthropic API key is required")
}
try {
this.client = new Anthropic({
apiKey: this.options.apiKey,
baseURL: this.options.anthropicBaseUrl || undefined,
})
} catch (error) {
throw new Error(`Error creating Anthropic client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
const modelId = model.id
@@ -44,7 +59,7 @@ export class AnthropicHandler implements ApiHandler {
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.client.messages.create(
stream = await client.messages.create(
{
model: modelId,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
@@ -118,7 +133,7 @@ export class AnthropicHandler implements ApiHandler {
break
}
default: {
stream = await this.client.messages.create({
stream = await client.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
+22 -11
View File
@@ -7,26 +7,37 @@ import { ApiStream } from "@api/transform/stream"
export class CerebrasHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Cerebras
private client: Cerebras | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
}
// Clean and validate the API key
const cleanApiKey = this.options.cerebrasApiKey?.trim()
private ensureClient(): Cerebras {
if (!this.client) {
// Clean and validate the API key
const cleanApiKey = this.options.cerebrasApiKey?.trim()
if (!cleanApiKey) {
throw new Error("Cerebras API key is required")
if (!cleanApiKey) {
throw new Error("Cerebras API key is required")
}
try {
this.client = new Cerebras({
apiKey: cleanApiKey,
timeout: 30000, // 30 second timeout
})
} catch (error) {
throw new Error(`Error creating Cerebras client: ${error.message}`)
}
}
this.client = new Cerebras({
apiKey: cleanApiKey,
timeout: 30000, // 30 second timeout
})
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
// Convert Anthropic messages to Cerebras format
const cerebrasMessages: Array<{
role: "system" | "user" | "assistant"
@@ -81,7 +92,7 @@ export class CerebrasHandler implements ApiHandler {
}
try {
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: cerebrasMessages,
temperature: 0,
+146 -100
View File
@@ -1,143 +1,192 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import axios from "axios"
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
import { OpenRouterErrorResponse } from "./types"
import { withRetry } from "../retry"
import { AuthService } from "@/services/auth/AuthService"
import OpenAI from "openai"
export class ClineHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private clineAccountService = ClineAccountService.getInstance()
private _authService: AuthService
private client: OpenAI | undefined
// TODO: replace this with a global API Host
private readonly _baseUrl = "https://api.cline.bot"
// private readonly _baseUrl = "https://core-api.staging.int.cline.bot"
// private readonly _baseUrl = "http://localhost:7777"
lastGenerationId?: string
private counter = 0
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.cline.bot/v1",
apiKey: this.options.clineApiKey || "",
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on cline.bot rankings.
"X-Title": "Cline", // Optional. Shows in rankings on cline.bot.
"X-Task-ID": this.options.taskId || "", // Include the task ID in the request headers
},
})
this._authService = AuthService.getInstance()
}
private async ensureClient(): Promise<OpenAI> {
if (!this.client) {
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error("Cline account authentication token is required")
}
try {
this.client = new OpenAI({
baseURL: `${this._baseUrl}/api/v1`,
apiKey: clineAccountAuthToken,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"X-Task-ID": this.options.taskId || "",
},
})
} catch (error: any) {
throw new Error(`Error creating Cline client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = await this.ensureClient()
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error("Unauthorized: Please sign in to Cline before trying again.")
}
this.lastGenerationId = undefined
const stream = await createOpenRouterStream(
this.client,
systemPrompt,
messages,
this.getModel(),
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
const me = await this.clineAccountService.fetchMe()
console.log(
"SwitchAuthToken: Active Organization",
me?.organizations.filter((org) => org.active)[0]?.name || "No active organization",
)
let didOutputUsage: boolean = false
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
}
try {
const stream = await createOpenRouterStream(
client,
systemPrompt,
messages,
this.getModel(),
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
)
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
}
// Check for mid-stream error via finish_reason
const choice = chunk.choices?.[0]
// OpenRouter may return finish_reason = "error" with error details
if ((choice?.finish_reason as string) === "error") {
const choiceWithError = choice as any
if (choiceWithError.error) {
const error = choiceWithError.error
console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
} else {
throw new Error("Cline Mid-Stream Error: Stream terminated with error status but no error details provided")
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
}
}
const delta = choice?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
}
}
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
// Check for mid-stream error via finish_reason
const choice = chunk.choices?.[0]
// OpenRouter may return finish_reason = "error" with error details
if ((choice?.finish_reason as string) === "error") {
const choiceWithError = choice as any
if (choiceWithError.error) {
const error = choiceWithError.error
console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
} else {
throw new Error(
"Cline Mid-Stream Error: Stream terminated with error status but no error details provided",
)
}
}
const delta = choice?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
}
}
if (!didOutputUsage && chunk.usage) {
// @ts-ignore-next-line
reasoning: delta.reasoning,
}
}
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
if (!didOutputUsage && chunk.usage) {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
const provider = modelId.split("/")[0]
// const provider = modelId.split("/")[0]
// // If provider is x-ai, set totalCost to 0 (we're doing a promo)
// if (provider === "x-ai") {
// totalCost = 0
// }
// If provider is x-ai, set totalCost to 0 (we're doing a promo)
if (provider === "x-ai") {
totalCost = 0
}
if (modelId.includes("gemini")) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
} else {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
if (modelId.includes("gemini")) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens:
(chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
} else {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
}
didOutputUsage = true
}
didOutputUsage = true
}
}
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
console.warn("Cline API did not return usage chunk, fetching from generation endpoint")
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
} catch (error) {
if (error.code === "ERR_BAD_REQUEST" || error.status === 401) {
throw new Error("Unauthorized: Please sign in to Cline before trying again.")
}
console.error("Cline API Error:", error)
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
try {
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
// TODO: replace this with firebase auth
// TODO: use global API Host
const response = await axios.get(`${this.clineAccountService.baseUrl}/generation?id=${this.lastGenerationId}`, {
headers: {
Authorization: `Bearer ${this.options.clineApiKey}`,
Authorization: `Bearer ${this.options.clineAccountId}`,
},
timeout: 15_000, // this request hangs sometimes
})
@@ -175,9 +224,6 @@ export class ClineHandler implements ApiHandler {
getModel(): { id: string; info: ModelInfo } {
let modelId = this.options.openRouterModelId
if (modelId === "x-ai/grok-3") {
modelId = "x-ai/grok-3-beta"
}
const modelInfo = this.options.openRouterModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
+20 -6
View File
@@ -10,14 +10,27 @@ import { convertToR1Format } from "../transform/r1-format"
export class DeepSeekHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.deepseek.com/v1",
apiKey: this.options.deepSeekApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.deepSeekApiKey) {
throw new Error("DeepSeek API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.deepseek.com/v1",
apiKey: this.options.deepSeekApiKey,
})
} catch (error) {
throw new Error(`Error creating DeepSeek client: ${error.message}`)
}
}
return this.client
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
@@ -54,6 +67,7 @@ export class DeepSeekHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const isDeepseekReasoner = model.id.includes("deepseek-reasoner")
@@ -67,7 +81,7 @@ export class DeepSeekHandler implements ApiHandler {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
+20 -6
View File
@@ -8,13 +8,26 @@ import { withRetry } from "../retry"
export class DoubaoHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
apiKey: this.options.doubaoApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.doubaoApiKey) {
throw new Error("Doubao API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
apiKey: this.options.doubaoApiKey,
})
} catch (error) {
throw new Error(`Error creating Doubao client: ${error.message}`)
}
}
return this.client
}
getModel(): { id: DoubaoModelId; info: ModelInfo } {
@@ -31,12 +44,13 @@ export class DoubaoHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
+20 -6
View File
@@ -15,18 +15,32 @@ import { ApiStream } from "../transform/stream"
export class FireworksHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.fireworks.ai/inference/v1",
apiKey: this.options.fireworksApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.fireworksApiKey) {
throw new Error("Fireworks API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.fireworks.ai/inference/v1",
apiKey: this.options.fireworksApiKey,
})
} catch (error) {
throw new Error(`Error creating Fireworks client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const modelId = this.options.fireworksModelId ?? ""
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -34,7 +48,7 @@ export class FireworksHandler implements ApiHandler {
...convertToOpenAiMessages(messages),
]
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: modelId,
...(this.options.fireworksModelMaxCompletionTokens
? { max_completion_tokens: this.options.fireworksModelMaxCompletionTokens }
+35 -18
View File
@@ -38,30 +38,45 @@ interface GeminiHandlerOptions extends ApiHandlerOptions {
*/
export class GeminiHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: GoogleGenAI
private client: GoogleGenAI | undefined
constructor(options: GeminiHandlerOptions) {
// Store the options
this.options = options
}
if (options.isVertex) {
// Initialize with Vertex AI configuration
const project = this.options.vertexProjectId ?? "not-provided"
const location = this.options.vertexRegion ?? "not-provided"
private ensureClient(): GoogleGenAI {
if (!this.client) {
const options = this.options as GeminiHandlerOptions
this.client = new GoogleGenAI({
vertexai: true,
project,
location,
})
} else {
// Initialize with standard API key
if (!options.geminiApiKey) {
throw new Error("API key is required for Google Gemini when not using Vertex AI")
if (options.isVertex) {
// Initialize with Vertex AI configuration
const project = this.options.vertexProjectId ?? "not-provided"
const location = this.options.vertexRegion ?? "not-provided"
try {
this.client = new GoogleGenAI({
vertexai: true,
project,
location,
})
} catch (error) {
throw new Error(`Error creating Gemini Vertex AI client: ${error.message}`)
}
} else {
// Initialize with standard API key
if (!options.geminiApiKey) {
throw new Error("API key is required for Google Gemini when not using Vertex AI")
}
try {
this.client = new GoogleGenAI({ apiKey: options.geminiApiKey })
} catch (error) {
throw new Error(`Error creating Gemini client: ${error.message}`)
}
}
this.client = new GoogleGenAI({ apiKey: options.geminiApiKey })
}
return this.client
}
/**
@@ -80,6 +95,7 @@ export class GeminiHandler implements ApiHandler {
maxDelay: 15000,
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const { id: modelId, info } = this.getModel()
const contents = messages.map(convertAnthropicMessageToGemini)
@@ -117,7 +133,7 @@ export class GeminiHandler implements ApiHandler {
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
try {
const result = await this.client.models.generateContentStream({
const result = await client.models.generateContentStream({
model: modelId,
contents: contents,
config: {
@@ -351,6 +367,7 @@ export class GeminiHandler implements ApiHandler {
*/
async countTokens(content: Array<any>): Promise<number> {
try {
const client = this.ensureClient()
const { id: model } = this.getModel()
// Convert content to Gemini format
@@ -362,7 +379,7 @@ export class GeminiHandler implements ApiHandler {
})
// Use Gemini's token counting API
const response = await this.client.models.countTokens({
const response = await client.models.countTokens({
model,
contents: [{ parts: geminiContent }],
})
+22 -7
View File
@@ -8,21 +8,35 @@ import { withRetry } from "../retry"
export class LiteLlmHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: this.options.liteLlmBaseUrl || "http://localhost:4000",
apiKey: this.options.liteLlmApiKey || "noop",
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.liteLlmApiKey) {
throw new Error("LiteLLM API key is required")
}
try {
this.client = new OpenAI({
baseURL: this.options.liteLlmBaseUrl || "http://localhost:4000",
apiKey: this.options.liteLlmApiKey || "noop",
})
} catch (error) {
throw new Error(`Error creating LiteLLM client: ${error.message}`)
}
}
return this.client
}
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
const client = this.ensureClient()
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
try {
const response = await fetch(`${this.client.baseURL}/spend/calculate`, {
const response = await fetch(`${client.baseURL}/spend/calculate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -54,6 +68,7 @@ export class LiteLlmHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const formattedMessages = convertToOpenAiMessages(messages)
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
@@ -101,7 +116,7 @@ export class LiteLlmHandler implements ApiHandler {
return message
})
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
messages: [enhancedSystemMessage, ...enhancedMessages],
temperature,
+17 -6
View File
@@ -8,25 +8,36 @@ import { withRetry } from "../retry"
export class LmStudioHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
apiKey: "noop",
})
}
private ensureClient(): OpenAI {
if (!this.client) {
try {
this.client = new OpenAI({
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
apiKey: "noop",
})
} catch (error) {
throw new Error(`Error creating LM Studio client: ${error.message}`)
}
}
return this.client
}
@withRetry({ retryAllErrors: true })
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
try {
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
stream: true,
+19 -5
View File
@@ -8,18 +8,32 @@ import { ApiStream } from "../transform/stream"
export class MistralHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Mistral
private client: Mistral | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Mistral({
apiKey: this.options.mistralApiKey,
})
}
private ensureClient(): Mistral {
if (!this.client) {
if (!this.options.mistralApiKey) {
throw new Error("Mistral API key is required")
}
try {
this.client = new Mistral({
apiKey: this.options.mistralApiKey,
})
} catch (error) {
throw new Error(`Error creating Mistral client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const stream = await this.client.chat
const client = this.ensureClient()
const stream = await client.chat
.stream({
model: this.getModel().id,
// max_completion_tokens: this.getModel().info.maxTokens,
+20 -7
View File
@@ -8,24 +8,37 @@ import { convertToR1Format } from "../transform/r1-format"
import { nebiusDefaultModelId, nebiusModels, type ModelInfo, type ApiHandlerOptions, type NebiusModelId } from "../../shared/api"
export class NebiusHandler implements ApiHandler {
private client: OpenAI
private client: OpenAI | undefined
constructor(private readonly options: ApiHandlerOptions) {
this.client = new OpenAI({
baseURL: "https://api.studio.nebius.ai/v1",
apiKey: this.options.nebiusApiKey,
})
constructor(private readonly options: ApiHandlerOptions) {}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.nebiusApiKey) {
throw new Error("Nebius API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.studio.nebius.ai/v1",
apiKey: this.options.nebiusApiKey,
})
} catch (error) {
throw new Error(`Error creating Nebius client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = model.id.includes("DeepSeek-R1")
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
messages: openAiMessages,
temperature: 0,
+14 -3
View File
@@ -8,15 +8,26 @@ import { withRetry } from "../retry"
export class OllamaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Ollama
private client: Ollama | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
}
private ensureClient(): Ollama {
if (!this.client) {
try {
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
} catch (error) {
throw new Error(`Error creating Ollama client: ${error.message}`)
}
}
return this.client
}
@withRetry({ retryAllErrors: true })
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
try {
@@ -27,7 +38,7 @@ export class OllamaHandler implements ApiHandler {
})
// Create the actual API request promise
const apiPromise = this.client.chat({
const apiPromise = client.chat({
model: this.getModel().id,
messages: ollamaMessages,
stream: true,
+21 -7
View File
@@ -10,13 +10,26 @@ import type { ChatCompletionReasoningEffort } from "openai/resources/chat/comple
export class OpenAiNativeHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
apiKey: this.options.openAiNativeApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.openAiNativeApiKey) {
throw new Error("OpenAI API key is required")
}
try {
this.client = new OpenAI({
apiKey: this.options.openAiNativeApiKey,
})
} catch (error: any) {
throw new Error(`Error creating OpenAI client: ${error.message}`)
}
}
return this.client
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
@@ -38,6 +51,7 @@ export class OpenAiNativeHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
switch (model.id) {
@@ -45,7 +59,7 @@ export class OpenAiNativeHandler implements ApiHandler {
case "o1-preview":
case "o1-mini": {
// o1 doesn't support streaming, non-1 temp, or system prompt
const response = await this.client.chat.completions.create({
const response = await client.chat.completions.create({
model: model.id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
})
@@ -61,7 +75,7 @@ export class OpenAiNativeHandler implements ApiHandler {
case "o4-mini":
case "o3":
case "o3-mini": {
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
@@ -85,7 +99,7 @@ export class OpenAiNativeHandler implements ApiHandler {
break
}
default: {
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
// max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
+36 -22
View File
@@ -10,35 +10,49 @@ import type { ChatCompletionReasoningEffort } from "openai/resources/chat/comple
export class OpenAiHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
if (
this.options.azureApiVersion ||
((this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") ||
this.options.openAiBaseUrl?.toLowerCase().includes("azure.us")) &&
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: this.options.openAiHeaders,
})
} else {
this.client = new OpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
defaultHeaders: this.options.openAiHeaders,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.openAiApiKey) {
throw new Error("OpenAI API key is required")
}
try {
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
if (
this.options.azureApiVersion ||
((this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") ||
this.options.openAiBaseUrl?.toLowerCase().includes("azure.us")) &&
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: this.options.openAiHeaders,
})
} else {
this.client = new OpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
defaultHeaders: this.options.openAiHeaders,
})
}
} catch (error: any) {
throw new Error(`Error creating OpenAI client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const modelId = this.options.openAiModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
const isR1FormatRequired = this.options.openAiModelInfo?.isR1FormatRequired ?? false
@@ -68,7 +82,7 @@ export class OpenAiHandler implements ApiHandler {
reasoningEffort = (this.options.reasoningEffort as ChatCompletionReasoningEffort) || "medium"
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: modelId,
messages: openAiMessages,
temperature,
+24 -13
View File
@@ -11,27 +11,41 @@ import { OpenRouterErrorResponse } from "./types"
export class OpenRouterHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: this.options.openRouterApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on openrouter.ai rankings.
"X-Title": "Cline", // Optional. Shows in rankings on openrouter.ai.
},
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.openRouterApiKey) {
throw new Error("OpenRouter API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: this.options.openRouterApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on openrouter.ai rankings.
"X-Title": "Cline", // Optional. Shows in rankings on openrouter.ai.
},
})
} catch (error: any) {
throw new Error(`Error creating OpenRouter client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
this.lastGenerationId = undefined
const stream = await createOpenRouterStream(
this.client,
client,
systemPrompt,
messages,
this.getModel(),
@@ -190,9 +204,6 @@ export class OpenRouterHandler implements ApiHandler {
getModel(): { id: string; info: ModelInfo } {
let modelId = this.options.openRouterModelId
if (modelId === "x-ai/grok-3") {
modelId = "x-ai/grok-3-beta"
}
const modelInfo = this.options.openRouterModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
+23 -9
View File
@@ -18,17 +18,30 @@ import { withRetry } from "../retry"
export class QwenHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL:
this.options.qwenApiLine === "china"
? "https://dashscope.aliyuncs.com/compatible-mode/v1"
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
apiKey: this.options.qwenApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.qwenApiKey) {
throw new Error("Alibaba API key is required")
}
try {
this.client = new OpenAI({
baseURL:
this.options.qwenApiLine === "china"
? "https://dashscope.aliyuncs.com/compatible-mode/v1"
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
apiKey: this.options.qwenApiKey,
})
} catch (error: any) {
throw new Error(`Error creating Alibaba client: ${error.message}`)
}
}
return this.client
}
getModel(): { id: MainlandQwenModelId | InternationalQwenModelId; info: ModelInfo } {
@@ -51,6 +64,7 @@ export class QwenHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const isDeepseekReasoner = model.id.includes("deepseek-r1")
const isReasoningModelFamily = model.id.includes("qwen3") || ["qwen-plus-latest", "qwen-turbo-latest"].includes(model.id)
@@ -76,7 +90,7 @@ export class QwenHandler implements ApiHandler {
temperature = undefined
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
+24 -10
View File
@@ -19,22 +19,36 @@ interface RequestyUsage extends OpenAI.CompletionUsage {
export class RequestyHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://router.requesty.ai/v1",
apiKey: this.options.requestyApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
},
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.requestyApiKey) {
throw new Error("Requesty API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://router.requesty.ai/v1",
apiKey: this.options.requestyApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
},
})
} catch (error: any) {
throw new Error(`Error creating Requesty client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -57,7 +71,7 @@ export class RequestyHandler implements ApiHandler {
: {}
// @ts-ignore-next-line
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: model.info.maxTokens || undefined,
messages: openAiMessages,
+20 -6
View File
@@ -9,18 +9,32 @@ import { convertToR1Format } from "@api/transform/r1-format"
export class SambanovaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.sambanova.ai/v1",
apiKey: this.options.sambanovaApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.sambanovaApiKey) {
throw new Error("SambaNova API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.sambanova.ai/v1",
apiKey: this.options.sambanovaApiKey,
})
} catch (error: any) {
throw new Error(`Error creating SambaNova client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -34,7 +48,7 @@ export class SambanovaHandler implements ApiHandler {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: 0,
+3 -4
View File
@@ -60,6 +60,7 @@ export class SapAiCoreHandler implements ApiHandler {
Authorization: `Bearer ${token}`,
"AI-Resource-Group": this.options.sapAiResourceGroup || "default",
"Content-Type": "application/json",
"AI-Client-Type": "Cline",
}
const url = `${this.options.sapAiCoreBaseUrl}/v2/lm/deployments?$top=10000&$skip=0`
@@ -116,6 +117,7 @@ export class SapAiCoreHandler implements ApiHandler {
Authorization: `Bearer ${token}`,
"AI-Resource-Group": this.options.sapAiResourceGroup || "default",
"Content-Type": "application/json",
"AI-Client-Type": "Cline",
}
const model = this.getModel()
@@ -283,7 +285,6 @@ export class SapAiCoreHandler implements ApiHandler {
const jsonData = line.slice(6)
try {
const data = JSON.parse(jsonData)
console.log("Received data:", data)
if (data.type === "message_start") {
usage.input_tokens = data.message.usage.input_tokens
yield {
@@ -346,7 +347,6 @@ export class SapAiCoreHandler implements ApiHandler {
try {
// Parse the incoming JSON data from the stream
const data = JSON.parse(toStrictJson(jsonData))
console.log("Received data:", data)
// Handle metadata (token usage)
if (data.metadata?.usage) {
@@ -422,7 +422,6 @@ export class SapAiCoreHandler implements ApiHandler {
const jsonData = line.slice(6)
try {
const data = JSON.parse(jsonData)
console.log("Received GPT data:", data)
if (data.choices && data.choices.length > 0) {
const choice = data.choices[0]
@@ -446,7 +445,7 @@ export class SapAiCoreHandler implements ApiHandler {
}
}
if (data.choices && data.choices[0].finish_reason === "stop") {
if (data.choices?.[0]?.finish_reason === "stop") {
// Final usage yield, if not already provided
if (!data.usage) {
yield {
+20 -6
View File
@@ -9,18 +9,32 @@ import { convertToR1Format } from "@api/transform/r1-format"
export class TogetherHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.together.xyz/v1",
apiKey: this.options.togetherApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.togetherApiKey) {
throw new Error("Together API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.together.xyz/v1",
apiKey: this.options.togetherApiKey,
})
} catch (error: any) {
throw new Error(`Error creating Together client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const modelId = this.options.togetherModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
@@ -33,7 +47,7 @@ export class TogetherHandler implements ApiHandler {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: modelId,
messages: openAiMessages,
temperature: 0,
+43 -16
View File
@@ -7,25 +7,49 @@ import { ApiStream } from "@api/transform/stream"
import { GeminiHandler } from "./gemini"
export class VertexHandler implements ApiHandler {
private geminiHandler: GeminiHandler
private clientAnthropic: AnthropicVertex
private geminiHandler: GeminiHandler | undefined
private clientAnthropic: AnthropicVertex | undefined
private options: ApiHandlerOptions
constructor(options: ApiHandlerOptions) {
this.options = options
}
// Create a GeminiHandler with isVertex flag for Gemini models
this.geminiHandler = new GeminiHandler({
...options,
isVertex: true,
})
private ensureGeminiHandler(): GeminiHandler {
if (!this.geminiHandler) {
try {
// Create a GeminiHandler with isVertex flag for Gemini models
this.geminiHandler = new GeminiHandler({
...this.options,
isVertex: true,
})
} catch (error: any) {
throw new Error(`Error creating Vertex AI Gemini handler: ${error.message}`)
}
}
return this.geminiHandler
}
// Initialize Anthropic client for Claude models
this.clientAnthropic = new AnthropicVertex({
projectId: this.options.vertexProjectId,
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
region: this.options.vertexRegion,
})
private ensureAnthropicClient(): AnthropicVertex {
if (!this.clientAnthropic) {
if (!this.options.vertexProjectId) {
throw new Error("Vertex AI project ID is required")
}
if (!this.options.vertexRegion) {
throw new Error("Vertex AI region is required")
}
try {
// Initialize Anthropic client for Claude models
this.clientAnthropic = new AnthropicVertex({
projectId: this.options.vertexProjectId,
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
region: this.options.vertexRegion,
})
} catch (error: any) {
throw new Error(`Error creating Vertex AI Anthropic client: ${error.message}`)
}
}
return this.clientAnthropic
}
@withRetry()
@@ -35,10 +59,13 @@ export class VertexHandler implements ApiHandler {
// For Gemini models, use the GeminiHandler
if (!modelId.includes("claude")) {
yield* this.geminiHandler.createMessage(systemPrompt, messages)
const geminiHandler = this.ensureGeminiHandler()
yield* geminiHandler.createMessage(systemPrompt, messages)
return
}
const clientAnthropic = this.ensureAnthropicClient()
// Claude implementation
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn =
@@ -63,7 +90,7 @@ export class VertexHandler implements ApiHandler {
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.clientAnthropic.beta.messages.create(
stream = await clientAnthropic.beta.messages.create(
{
model: modelId,
max_tokens: model.info.maxTokens || 8192,
@@ -125,7 +152,7 @@ export class VertexHandler implements ApiHandler {
break
}
default: {
stream = await this.clientAnthropic.beta.messages.create({
stream = await clientAnthropic.beta.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
+20 -6
View File
@@ -9,18 +9,32 @@ import { withRetry } from "../retry"
export class XAIHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.x.ai/v1",
apiKey: this.options.xaiApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.xaiApiKey) {
throw new Error("xAI API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.x.ai/v1",
apiKey: this.options.xaiApiKey,
})
} catch (error: any) {
throw new Error(`Error creating xAI client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const modelId = this.getModel().id
// ensure reasoning effort is either "low" or "high" for grok-3-mini
let reasoningEffort: ChatCompletionReasoningEffort | undefined
@@ -30,7 +44,7 @@ export class XAIHandler implements ApiHandler {
reasoningEffort = undefined
}
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: modelId,
max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
@@ -1,8 +1,9 @@
import * as vscode from "vscode"
import crypto from "crypto"
import { Controller } from "../index"
import { storeSecret } from "../../storage/state"
import { AuthService } from "@/services/auth/AuthService"
import { EmptyRequest, String } from "../../../shared/proto/common"
import { openExternal } from "@utils/env"
const authService = AuthService.getInstance()
/**
* Handles the user clicking the login link in the UI.
@@ -13,21 +14,5 @@ import { EmptyRequest, String } from "../../../shared/proto/common"
* @returns The login URL as a string.
*/
export async function accountLoginClicked(controller: Controller, _: EmptyRequest): Promise<String> {
// Generate nonce for state validation
const nonce = crypto.randomBytes(32).toString("hex")
await storeSecret(controller.context, "authNonce", nonce)
// Open browser for authentication with state param
console.log("Login button clicked in account page")
console.log("Opening auth page with state param")
const uriScheme = vscode.env.uriScheme
const authUrl = vscode.Uri.parse(
`https://app.cline.bot/auth?state=${encodeURIComponent(nonce)}&callback_url=${encodeURIComponent(`${uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`)}`,
)
await vscode.env.openExternal(authUrl)
return String.create({
value: authUrl.toString(),
})
return await authService.createAuthRequest()
}
@@ -1,7 +1,9 @@
import { AuthService } from "@/services/auth/AuthService"
import { Empty } from "../../../shared/proto/common"
import type { EmptyRequest } from "../../../shared/proto/common"
import type { Controller } from "../index"
const authService = AuthService.getInstance()
/**
* Handles the account logout action
* @param controller The controller instance
@@ -10,5 +12,6 @@ import type { Controller } from "../index"
*/
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
await controller.handleSignOut()
await authService.handleDeauth()
return Empty.create({})
}
@@ -1,4 +1,4 @@
import { AuthStateChangedRequest, AuthStateChanged } from "@shared/proto/account"
import { AuthStateChangedRequest, AuthState } from "@shared/proto/account"
import type { Controller } from "../index"
import { updateGlobalState } from "../../storage/state"
@@ -9,13 +9,13 @@ import { updateGlobalState } from "../../storage/state"
* @param request The auth state change request
* @returns The updated user info
*/
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthStateChanged> {
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthState> {
try {
// Store the user info directly in global state
await updateGlobalState(controller.context, "userInfo", request.user)
// Return the same user info
return AuthStateChanged.create({ user: request.user })
return AuthState.create({ user: request.user })
} catch (error) {
console.error(`Failed to update auth state: ${error}`)
throw error
@@ -0,0 +1,50 @@
import type { Controller } from "../index"
import { GetOrganizationCreditsRequest, OrganizationCreditsData, OrganizationUsageTransaction } from "@shared/proto/account"
/**
* Handles fetching all organization credits data (balance, usage, payments)
* @param controller The controller instance
* @param request Organization credits request
* @returns Organization credits data response
*/
export async function getOrganizationCredits(
controller: Controller,
request: GetOrganizationCreditsRequest,
): Promise<OrganizationCreditsData> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Call the individual RPC variants in parallel
const [balanceData, usageTransactions] = await Promise.all([
controller.accountService.fetchOrganizationCreditsRPC(request.organizationId),
controller.accountService.fetchOrganizationUsageTransactionsRPC(request.organizationId),
])
return OrganizationCreditsData.create({
balance: balanceData ? { currentBalance: balanceData.balance / 100 } : { currentBalance: 0 },
organizationId: balanceData?.organizationId || "",
usageTransactions:
usageTransactions?.map((tx) =>
OrganizationUsageTransaction.create({
aiInferenceProviderName: tx.aiInferenceProviderName,
aiModelName: tx.aiModelName,
aiModelTypeName: tx.aiModelTypeName,
completionTokens: tx.completionTokens,
costUsd: tx.costUsd,
createdAt: tx.createdAt,
creditsUsed: tx.creditsUsed,
generationId: tx.generationId,
organizationId: tx.organizationId,
promptTokens: tx.promptTokens,
totalTokens: tx.totalTokens,
userId: tx.userId,
}),
) || [],
})
} catch (error) {
console.error(`Failed to fetch organization credits data: ${error}`)
throw error
}
}
@@ -8,7 +8,7 @@ import { UserCreditsData } from "@shared/proto/account"
* @param request Empty request
* @returns User credits data response
*/
export async function fetchUserCreditsData(controller: Controller, request: EmptyRequest): Promise<UserCreditsData> {
export async function getUserCredits(controller: Controller, request: EmptyRequest): Promise<UserCreditsData> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
@@ -21,11 +21,10 @@ export async function fetchUserCreditsData(controller: Controller, request: Empt
controller.accountService.fetchPaymentTransactionsRPC(),
])
// Since generated types match exactly, no conversion needed!
return UserCreditsData.create({
balance: balance ? { currentBalance: balance.currentBalance } : { currentBalance: 0 },
usageTransactions: usageTransactions || [],
paymentTransactions: paymentTransactions || [],
balance: balance ? { currentBalance: balance.balance / 100 } : { currentBalance: 0 },
usageTransactions: usageTransactions,
paymentTransactions: paymentTransactions,
})
} catch (error) {
console.error(`Failed to fetch user credits data: ${error}`)
@@ -0,0 +1,35 @@
import type { Controller } from "../index"
import type { EmptyRequest } from "@shared/proto/common"
import { UserOrganization, UserOrganizationsResponse } from "@shared/proto/account"
/**
* Handles fetching all user credits data (balance, usage, payments)
* @param controller The controller instance
* @param request Empty request
* @returns User credits data response
*/
export async function getUserOrganizations(controller: Controller, request: EmptyRequest): Promise<UserOrganizationsResponse> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Fetch user organizations from the account service
const organizations = await controller.accountService.fetchUserOrganizationsRPC()
return UserOrganizationsResponse.create({
organizations:
organizations?.map((org) =>
UserOrganization.create({
active: org.active,
memberId: org.memberId,
name: org.name,
organizationId: org.organizationId,
roles: org.roles ? [...org.roles] : [],
}),
) || [],
})
} catch (error) {
throw error
}
}
@@ -0,0 +1,24 @@
import type { Controller } from "../index"
import { Empty } from "@shared/proto/common"
import { UserOrganizationUpdateRequest } from "@shared/proto/account"
/**
* Handles setting the user's active organization
* @param controller The controller instance
* @param request UserOrganization to set as active
* @returns Empty response
*/
export async function setUserOrganization(controller: Controller, request: UserOrganizationUpdateRequest): Promise<Empty> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Switch to the specified organization using the account service
await controller.accountService.switchAccount(request.organizationId)
return Empty.create({})
} catch (error) {
throw error
}
}
@@ -1,59 +0,0 @@
import { Controller } from "../index"
import { EmptyRequest } from "../../../shared/proto/common"
import { String as ProtoString } from "../../../shared/proto/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
// Keep track of active authCallback subscriptions
const activeAuthCallbackSubscriptions = new Set<StreamingResponseHandler>()
/**
* Subscribe to authCallback events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToAuthCallback(
controller: Controller,
request: EmptyRequest,
responseStream: StreamingResponseHandler,
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
activeAuthCallbackSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeAuthCallbackSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "authCallback_subscription" }, responseStream)
}
}
/**
* Send an authCallback event to all active subscribers
* @param customToken The custom token for authentication
*/
export async function sendAuthCallbackEvent(customToken: string): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activeAuthCallbackSubscriptions).map(async (responseStream) => {
try {
const event: ProtoString = {
value: customToken,
}
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending authCallback event:", error)
// Remove the subscription if there was an error
activeAuthCallbackSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
@@ -0,0 +1,5 @@
import { AuthService } from "../../../services/auth/AuthService"
const authService = AuthService.getInstance()
export const subscribeToAuthStatusUpdate = authService.subscribeToAuthStatusUpdate.bind(authService)
export const sendAuthStatusUpdateEvent = authService.sendAuthStatusUpdate.bind(authService)
+29 -44
View File
@@ -1,10 +1,10 @@
import { Controller } from ".."
import { RelativePathsRequest, RelativePaths } from "@shared/proto/file"
import { FileMethodHandler } from "./index"
import * as vscode from "vscode"
import { asRelativePath } from "@/utils/path"
import { RelativePaths, RelativePathsRequest } from "@shared/proto/file"
import * as path from "path"
import { StringRequest } from "@shared/proto/common"
import { getHostBridgeProvider } from "@hosts/host-providers"
import { URI } from "vscode-uri"
import { Controller } from ".."
import { FileMethodHandler } from "./index"
import { isDirectory } from "@/utils/fs"
/**
* Converts a list of URIs to workspace-relative paths
@@ -13,47 +13,32 @@ import { getHostBridgeProvider } from "@hosts/host-providers"
* @returns Response with resolved relative paths
*/
export const getRelativePaths: FileMethodHandler = async (
controller: Controller,
_controller: Controller,
request: RelativePathsRequest,
): Promise<RelativePaths> => {
const resolvedPaths = await Promise.all(
request.uris.map(async (uriString) => {
try {
// Use the host URI service client instead of directly using vscode.Uri.parse
const parseResponse = await getHostBridgeProvider().uriServiceClient.parse(
StringRequest.create({
value: uriString,
}),
)
const fileUri = vscode.Uri.parse(`${parseResponse.scheme}://${parseResponse.authority}${parseResponse.path}`)
console.log("[DEBUG] UriServiceClient.parse:", fileUri)
const relativePathToGet = vscode.workspace.asRelativePath(fileUri, false)
const result = []
for (const uriString of request.uris) {
try {
result.push(await getRelativePath(uriString))
} catch (error) {
console.error(`Error calculating relative path for ${uriString}:`, error)
}
}
return RelativePaths.create({ paths: result })
}
// If the path is still absolute, it's outside the workspace
if (path.isAbsolute(relativePathToGet)) {
console.warn(`Dropped file ${relativePathToGet} is outside the workspace. Sending original path.`)
return fileUri.fsPath.replace(/\\/g, "/")
} else {
let finalPath = "/" + relativePathToGet.replace(/\\/g, "/")
try {
const stat = await vscode.workspace.fs.stat(fileUri)
if (stat.type === vscode.FileType.Directory) {
finalPath += "/"
}
} catch (statError) {
console.error(`Error stating file ${fileUri.fsPath}:`, statError)
}
return finalPath
}
} catch (error) {
console.error(`Error calculating relative path for ${uriString}:`, error)
return null
}
}),
)
async function getRelativePath(uriString: string): Promise<string> {
const filePath = URI.parse(uriString, true).fsPath
const relativePath = await asRelativePath(filePath)
// Filter out any null values from errors
const validPaths = resolvedPaths.filter((path): path is string => path !== null)
// If the path is still absolute, it's outside the workspace
if (path.isAbsolute(relativePath)) {
throw new Error(`Dropped file ${relativePath} is outside the workspace.`)
}
return RelativePaths.create({ paths: validPaths })
let result = "/" + relativePath.replace(/\\/g, "/")
if (await isDirectory(filePath)) {
result += "/"
}
return result
}
+32 -31
View File
@@ -30,18 +30,17 @@ import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalF
import {
getAllExtensionState,
getGlobalState,
getSecret,
getWorkspaceState,
storeSecret,
updateGlobalState,
updateWorkspaceState,
} from "../storage/state"
import { Task } from "../task"
import { sendAuthCallbackEvent } from "./account/subscribeToAuthCallback"
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { sendStateUpdate } from "./state/subscribeToState"
import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { AuthService } from "@/services/auth/AuthService"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -54,11 +53,11 @@ export class Controller {
private postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined
private disposables: vscode.Disposable[] = []
private mode: "plan" | "act" = "plan" // In-memory plan/act mode state
task?: Task
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
accountService: ClineAccountService
authService: AuthService
latestAnnouncementId = "june-25-2025_16:11:00" // update to some unique identifier when we add a new announcement
constructor(
@@ -78,10 +77,9 @@ export class Controller {
(msg) => this.postMessageToWebview(msg),
this.context.extension?.packageJSON?.version ?? "1.0.0",
)
this.accountService = new ClineAccountService(async () => {
const { apiConfiguration } = await this.getStateToPostToWebview()
return apiConfiguration?.clineApiKey
})
this.accountService = ClineAccountService.getInstance()
this.authService = AuthService.getInstance(context)
this.authService.restoreAuthToken()
// Clean up legacy checkpoints
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath, this.outputChannel).catch((error) => {
@@ -89,6 +87,10 @@ export class Controller {
})
}
private async getCurrentMode(): Promise<"plan" | "act"> {
return ((await getGlobalState(this.context, "mode")) as "plan" | "act" | undefined) || "act"
}
/*
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
@@ -111,7 +113,8 @@ export class Controller {
// Auth methods
async handleSignOut() {
try {
await storeSecret(this.context, "clineApiKey", undefined)
// TODO: update to clineAccountId and then move clineApiKey to a clear function.
await storeSecret(this.context, "clineAccountId", undefined)
await updateGlobalState(this.context, "userInfo", undefined)
await updateGlobalState(this.context, "apiProvider", "openrouter")
await this.postStateToWebview()
@@ -141,10 +144,13 @@ export class Controller {
taskHistory,
} = await getAllExtensionState(this.context)
// Reconstruct ChatSettings with in-memory mode and stored preferences
// Get current mode using helper function
const currentMode = await this.getCurrentMode()
// Reconstruct ChatSettings with mode from global state and stored preferences
const chatSettings: ChatSettings = {
...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort)
mode: this.mode, // Use in-memory mode (override any stored mode)
mode: currentMode, // Use mode from global state
}
const NEW_USER_TASK_COUNT_THRESHOLD = 10
@@ -239,8 +245,8 @@ export class Controller {
async togglePlanActModeWithChatSettings(chatSettings: ChatSettings, chatContent?: ChatContent): Promise<boolean> {
const didSwitchToActMode = chatSettings.mode === "act"
// Store mode in-memory only
this.mode = chatSettings.mode
// Store mode to global state
await updateGlobalState(this.context, "mode", chatSettings.mode)
// Capture mode switch telemetry | Capture regardless of if we know the taskId
telemetryService.captureModeSwitch(this.task?.taskId ?? "0", chatSettings.mode)
@@ -394,7 +400,7 @@ export class Controller {
}
}
// Save only non-mode properties to workspace storage
// Save only non-mode properties to global storage
const { mode, ...persistentChatSettings }: { mode: string } & StoredChatSettings = chatSettings
await updateGlobalState(this.context, "chatSettings", persistentChatSettings)
await this.postStateToWebview()
@@ -451,33 +457,29 @@ export class Controller {
}
// Auth
public async validateAuthState(state: string | null): Promise<boolean> {
const storedNonce = await getSecret(this.context, "authNonce")
const storedNonce = this.authService.authNonce
if (!state || state !== storedNonce) {
return false
}
await storeSecret(this.context, "authNonce", undefined) // Clear after use
this.authService.resetAuthNonce() // Clear the nonce after validation
return true
}
async handleAuthCallback(customToken: string, apiKey: string) {
async handleAuthCallback(customToken: string, provider: string | null = null) {
try {
// Store API key for API calls
await storeSecret(this.context, "clineApiKey", apiKey)
// Send custom token to webview for Firebase auth
await sendAuthCallbackEvent(customToken)
await this.authService.handleAuthCallback(customToken, provider ? provider : "google")
const clineProvider: ApiProvider = "cline"
await updateGlobalState(this.context, "apiProvider", clineProvider)
// Update API configuration with the new provider and API key
// Mark welcome view as completed since user has successfully logged in
await updateGlobalState(this.context, "welcomeViewCompleted", true)
const { apiConfiguration } = await getAllExtensionState(this.context)
const updatedConfig = {
...apiConfiguration,
apiProvider: clineProvider,
clineApiKey: apiKey,
}
if (this.task) {
@@ -485,7 +487,6 @@ export class Controller {
}
await this.postStateToWebview()
// vscode.window.showInformationMessage("Successfully logged in to Cline")
} catch (error) {
console.error("Failed to handle auth callback:", error)
vscode.window.showErrorMessage("Failed to log in to Cline")
@@ -495,7 +496,6 @@ export class Controller {
}
// MCP Marketplace
private async fetchMcpMarketplaceFromApi(silent: boolean = false): Promise<McpMarketplaceCatalog | undefined> {
try {
const response = await axios.get("https://api.cline.bot/v1/mcp/marketplace", {
@@ -834,10 +834,13 @@ export class Controller {
terminalOutputLineLimit,
} = await getAllExtensionState(this.context)
// Reconstruct ChatSettings with in-memory mode and stored preferences
// Get current mode using helper function
const currentMode = await this.getCurrentMode()
// Reconstruct ChatSettings with mode from global state and stored preferences
const chatSettings: ChatSettings = {
...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort)
mode: this.mode, // Use in-memory mode (override any stored mode)
mode: currentMode, // Use mode from global state
}
const localClineRulesToggles =
@@ -1064,6 +1067,4 @@ Commit message:`
vscode.window.showErrorMessage(`Failed to generate commit message: ${errorMessage}`)
}
}
// dev
}
+26 -18
View File
@@ -1,6 +1,7 @@
import { Controller } from ".."
import { Empty, StringRequest } from "../../../shared/proto/common"
import { McpServer, McpDownloadResponse } from "@shared/mcp"
import { StringRequest } from "../../../shared/proto/common"
import { McpDownloadResponse } from "../../../shared/proto/mcp"
import { McpServer } from "@shared/mcp"
import axios from "axios"
import * as vscode from "vscode"
import { sendChatButtonClickedEvent } from "../ui/subscribeToChatButtonClicked"
@@ -9,9 +10,9 @@ import { sendChatButtonClickedEvent } from "../ui/subscribeToChatButtonClicked"
* Download an MCP server from the marketplace
* @param controller The controller instance
* @param request The request containing the MCP ID
* @returns Empty response
* @returns MCP download response with details or error
*/
export async function downloadMcp(controller: Controller, request: StringRequest): Promise<Empty> {
export async function downloadMcp(controller: Controller, request: StringRequest): Promise<McpDownloadResponse> {
try {
// Check if mcpId is provided
if (!request.value) {
@@ -54,12 +55,6 @@ export async function downloadMcp(controller: Controller, request: StringRequest
throw new Error("Missing README content in MCP download response")
}
// Send details to webview
await controller.postMessageToWebview({
type: "mcpDownloadDetails",
mcpDownloadDetails: mcpDetails,
})
// Create task with context from README and added guidelines for MCP server installation
const task = `Set up the MCP server from ${mcpDetails.githubUrl} while adhering to these MCP server installation rules:
- Start by loading the MCP documentation.
@@ -80,8 +75,17 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
await controller.initTask(task)
await sendChatButtonClickedEvent(controller.id)
// Return an empty response - the client only cares if the call succeeded
return Empty.create()
// Return the download details directly
return McpDownloadResponse.create({
mcpId: mcpDetails.mcpId,
githubUrl: mcpDetails.githubUrl,
name: mcpDetails.name,
author: mcpDetails.author,
description: mcpDetails.description,
readmeContent: mcpDetails.readmeContent,
llmsInstallationContent: mcpDetails.llmsInstallationContent,
requiresApiKey: mcpDetails.requiresApiKey,
})
} catch (error) {
console.error("Failed to download MCP:", error)
let errorMessage = "Failed to download MCP"
@@ -100,13 +104,17 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
errorMessage = error.message
}
// Show error in both notification and marketplace UI
vscode.window.showErrorMessage(errorMessage)
await controller.postMessageToWebview({
type: "mcpDownloadDetails",
// Return error in the response instead of throwing
return McpDownloadResponse.create({
mcpId: "",
githubUrl: "",
name: "",
author: "",
description: "",
readmeContent: "",
llmsInstallationContent: "",
requiresApiKey: false,
error: errorMessage,
})
throw error
}
}
@@ -101,7 +101,7 @@ export async function refreshOpenRouterModels(
break
case "x-ai/grok-3-beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 0
modelInfo.cacheWritesPrice = 0.75
modelInfo.cacheReadsPrice = 0
break
default:
@@ -122,11 +122,6 @@ export async function refreshOpenRouterModels(
break
}
// add new model id
if (rawModel.id === "x-ai/grok-3-beta") {
models["x-ai/grok-3"] = modelInfo
}
models[rawModel.id] = modelInfo
}
} else {
+10 -1
View File
@@ -58,7 +58,16 @@ export async function updateSettings(controller: Controller, request: UpdateSett
// Update chat settings
if (request.chatSettings) {
const chatSettings = convertProtoChatSettingsToChatSettings(request.chatSettings)
await controller.context.workspaceState.update("chatSettings", chatSettings)
// Store mode to global state
if (chatSettings.mode !== undefined) {
await controller.context.globalState.update("mode", chatSettings.mode)
}
// Store chat settings (excluding mode) to global state
const { mode, ...globalChatSettings } = chatSettings
await controller.context.globalState.update("chatSettings", globalChatSettings)
if (controller.task) {
controller.task.chatSettings = chatSettings
}
-1
View File
@@ -1,7 +1,6 @@
import { Controller } from ".."
import { Empty } from "../../../shared/proto/common"
import { NewTaskRequest } from "../../../shared/proto/task"
import { handleFileServiceRequest } from "../file"
/**
* Creates a new task with the given text and optional images
+2 -2
View File
@@ -1,6 +1,6 @@
import * as vscode from "vscode"
import { Controller } from ".."
import { Empty, StringRequest } from "../../../shared/proto/common"
import { openExternal } from "@utils/env"
/**
* Opens a URL in the user's default browser
@@ -11,7 +11,7 @@ import { Empty, StringRequest } from "../../../shared/proto/common"
export async function openInBrowser(controller: Controller, request: StringRequest): Promise<Empty> {
try {
if (request.value) {
await vscode.env.openExternal(vscode.Uri.parse(request.value))
await openExternal(request.value)
}
return Empty.create()
} catch (error) {
+2 -1
View File
@@ -12,6 +12,7 @@ import { getCommitInfo } from "@utils/git"
import { getWorkingState } from "@utils/git"
import { FileContextTracker } from "../context/context-tracking/FileContextTracker"
import { getCwd } from "@/utils/path"
import { openExternal } from "@utils/env"
export async function openMention(mention?: string): Promise<void> {
if (!mention) {
@@ -36,7 +37,7 @@ export async function openMention(mention?: string): Promise<void> {
} else if (mention === "terminal") {
vscode.commands.executeCommand("workbench.action.terminal.focus")
} else if (mention.startsWith("http")) {
vscode.env.openExternal(vscode.Uri.parse(mention))
await openExternal(mention)
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
export type SecretKey =
| "apiKey"
| "clineApiKey"
| "clineAccountId"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
@@ -80,6 +80,7 @@ export type GlobalStateKey =
| "claudeCodePath"
// Settings around plan/act and ephemeral model configuration
| "chatSettings"
| "mode"
// Current active model configuration (per workspace)
| "apiProvider"
| "apiModelId"
+1 -1
View File
@@ -202,7 +202,7 @@ export async function migrateWelcomeViewCompleted(context: vscode.ExtensionConte
config.doubaoApiKey,
config.mistralApiKey,
config.vsCodeLmModelSelector,
config.clineApiKey,
config.clineAccountId,
config.asksageApiKey,
config.xaiApiKey,
config.sambanovaApiKey,
+155 -85
View File
@@ -18,19 +18,66 @@ import { migrateEnableCheckpointsSetting, migrateMcpMarketplaceEnableSetting } f
https://www.eliostruyf.com/devhack-code-extension-storage-options/
*/
// global
const isTemporaryProfile = process.env.TEMP_PROFILE === "true"
// In-memory storage for temporary profiles
const inMemoryGlobalState = new Map<string, any>()
const inMemoryWorkspaceState = new Map<string, any>()
const inMemorySecrets = new Map<string, string>()
// global
export async function updateGlobalState(context: vscode.ExtensionContext, key: GlobalStateKey, value: any) {
if (isTemporaryProfile) {
inMemoryGlobalState.set(key, value)
return
}
await context.globalState.update(key, value)
}
export async function getGlobalState(context: vscode.ExtensionContext, key: GlobalStateKey) {
if (isTemporaryProfile) {
return inMemoryGlobalState.get(key)
}
return await context.globalState.get(key)
}
// secrets
// Batched operations for performance optimization
export async function updateGlobalStateBatch(context: vscode.ExtensionContext, updates: Record<string, any>) {
if (isTemporaryProfile) {
Object.entries(updates).forEach(([key, value]) => {
inMemoryGlobalState.set(key, value)
})
return
}
// Use Promise.all to batch the updates
await Promise.all(Object.entries(updates).map(([key, value]) => context.globalState.update(key as GlobalStateKey, value)))
}
export async function updateSecretsBatch(context: vscode.ExtensionContext, updates: Record<string, string | undefined>) {
if (isTemporaryProfile) {
Object.entries(updates).forEach(([key, value]) => {
if (value) {
inMemorySecrets.set(key, value)
} else {
inMemorySecrets.delete(key)
}
})
return
}
// Use Promise.all to batch the secret updates
await Promise.all(Object.entries(updates).map(([key, value]) => storeSecret(context, key as SecretKey, value)))
}
// secrets
export async function storeSecret(context: vscode.ExtensionContext, key: SecretKey, value?: string) {
if (isTemporaryProfile) {
if (value) {
inMemorySecrets.set(key, value)
} else {
inMemorySecrets.delete(key)
}
return
}
if (value) {
await context.secrets.store(key, value)
} else {
@@ -39,26 +86,36 @@ export async function storeSecret(context: vscode.ExtensionContext, key: SecretK
}
export async function getSecret(context: vscode.ExtensionContext, key: SecretKey) {
if (isTemporaryProfile) {
return inMemorySecrets.get(key)
}
return await context.secrets.get(key)
}
// workspace
export async function updateWorkspaceState(context: vscode.ExtensionContext, key: LocalStateKey, value: any) {
if (isTemporaryProfile) {
inMemoryWorkspaceState.set(key, value)
return
}
await context.workspaceState.update(key, value)
}
export async function getWorkspaceState(context: vscode.ExtensionContext, key: LocalStateKey) {
if (isTemporaryProfile) {
return inMemoryWorkspaceState.get(key)
}
return await context.workspaceState.get(key)
}
export async function getAllExtensionState(context: vscode.ExtensionContext) {
const firstBatchStart = performance.now()
const [
isNewUser,
welcomeViewCompleted,
apiKey,
openRouterApiKey,
clineApiKey,
clineAccountId,
awsAccessKey,
awsSecretKey,
awsSessionToken,
@@ -131,7 +188,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "welcomeViewCompleted") as Promise<boolean | undefined>,
getSecret(context, "apiKey") as Promise<string | undefined>,
getSecret(context, "openRouterApiKey") as Promise<string | undefined>,
getSecret(context, "clineApiKey") as Promise<string | undefined>,
getSecret(context, "clineAccountId") as Promise<string | undefined>,
getSecret(context, "awsAccessKey") as Promise<string | undefined>,
getSecret(context, "awsSecretKey") as Promise<string | undefined>,
getSecret(context, "awsSessionToken") as Promise<string | undefined>,
@@ -203,8 +260,10 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
const localClineRulesToggles = (await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles
const secondBatchStart = performance.now()
const [
chatSettings,
currentMode,
storedApiProvider,
apiModelId,
thinkingBudgetTokens,
@@ -236,6 +295,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sapAiCoreModelId,
] = await Promise.all([
getGlobalState(context, "chatSettings") as Promise<StoredChatSettings | undefined>,
getGlobalState(context, "mode") as Promise<"plan" | "act" | undefined>,
getGlobalState(context, "apiProvider") as Promise<ApiProvider | undefined>,
getGlobalState(context, "apiModelId") as Promise<string | undefined>,
getGlobalState(context, "thinkingBudgetTokens") as Promise<number | undefined>,
@@ -267,6 +327,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "sapAiCoreModelId") as Promise<string | undefined>,
])
const processingStart = performance.now()
let apiProvider: ApiProvider
if (storedApiProvider) {
apiProvider = storedApiProvider
@@ -309,7 +370,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
apiModelId,
apiKey,
openRouterApiKey,
clineApiKey,
clineAccountId,
claudeCodePath,
awsAccessKey,
awsSecretKey,
@@ -389,7 +450,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
browserSettings: { ...DEFAULT_BROWSER_SETTINGS, ...browserSettings }, // this will ensure that older versions of browserSettings (e.g. before remoteBrowserEnabled was added) are merged with the default values (false for remoteBrowserEnabled)
chatSettings: {
...DEFAULT_CHAT_SETTINGS, // Apply defaults first
...(chatSettings || {}), // Spread fetched chatSettings, which includes preferredLanguage, and openAIReasoningEffort
...(chatSettings || {}), // Spread fetched global chatSettings, which includes preferredLanguage, and openAIReasoningEffort
mode: currentMode || "act", // Merge mode from global state
},
userInfo,
previousModeApiProvider,
@@ -473,7 +535,7 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
xaiApiKey,
thinkingBudgetTokens,
reasoningEffort,
clineApiKey,
clineAccountId,
sambanovaApiKey,
cerebrasApiKey,
nebiusApiKey,
@@ -491,84 +553,92 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
claudeCodePath,
} = apiConfiguration
// Ephemeral model config updates
await updateGlobalState(context, "apiProvider", apiProvider)
await updateGlobalState(context, "apiModelId", apiModelId)
await updateGlobalState(context, "thinkingBudgetTokens", thinkingBudgetTokens)
await updateGlobalState(context, "reasoningEffort", reasoningEffort)
await updateGlobalState(context, "vsCodeLmModelSelector", vsCodeLmModelSelector)
await updateGlobalState(context, "awsBedrockCustomSelected", awsBedrockCustomSelected)
await updateGlobalState(context, "awsBedrockCustomModelBaseId", awsBedrockCustomModelBaseId)
await updateGlobalState(context, "openRouterModelId", openRouterModelId)
await updateGlobalState(context, "openRouterModelInfo", openRouterModelInfo)
await updateGlobalState(context, "openAiModelId", openAiModelId)
await updateGlobalState(context, "openAiModelInfo", openAiModelInfo)
await updateGlobalState(context, "ollamaModelId", ollamaModelId)
await updateGlobalState(context, "lmStudioModelId", lmStudioModelId)
await updateGlobalState(context, "liteLlmModelId", liteLlmModelId)
await updateGlobalState(context, "liteLlmModelInfo", liteLlmModelInfo)
await updateGlobalState(context, "requestyModelId", requestyModelId)
await updateGlobalState(context, "requestyModelInfo", requestyModelInfo)
await updateGlobalState(context, "togetherModelId", togetherModelId)
await updateGlobalState(context, "fireworksModelId", fireworksModelId)
await updateGlobalState(context, "sapAiCoreModelId", sapAiCoreModelId)
// OPTIMIZED: Batch all global state updates into 2 operations instead of 47
const batchedGlobalUpdates = {
// Ephemeral model config updates (20 keys)
apiProvider,
apiModelId,
thinkingBudgetTokens,
reasoningEffort,
vsCodeLmModelSelector,
awsBedrockCustomSelected,
awsBedrockCustomModelBaseId,
openRouterModelId,
openRouterModelInfo,
openAiModelId,
openAiModelInfo,
ollamaModelId,
lmStudioModelId,
liteLlmModelId,
liteLlmModelInfo,
requestyModelId,
requestyModelInfo,
togetherModelId,
fireworksModelId,
sapAiCoreModelId,
// Global state updates
await updateGlobalState(context, "awsRegion", awsRegion)
await updateGlobalState(context, "awsUseCrossRegionInference", awsUseCrossRegionInference)
await updateGlobalState(context, "awsBedrockUsePromptCache", awsBedrockUsePromptCache)
await updateGlobalState(context, "awsBedrockEndpoint", awsBedrockEndpoint)
await updateGlobalState(context, "awsProfile", awsProfile)
await updateGlobalState(context, "awsUseProfile", awsUseProfile)
await updateGlobalState(context, "vertexProjectId", vertexProjectId)
await updateGlobalState(context, "vertexRegion", vertexRegion)
await updateGlobalState(context, "openAiBaseUrl", openAiBaseUrl)
await updateGlobalState(context, "openAiHeaders", openAiHeaders || {})
await updateGlobalState(context, "ollamaBaseUrl", ollamaBaseUrl)
await updateGlobalState(context, "ollamaApiOptionsCtxNum", ollamaApiOptionsCtxNum)
await updateGlobalState(context, "lmStudioBaseUrl", lmStudioBaseUrl)
await updateGlobalState(context, "anthropicBaseUrl", anthropicBaseUrl)
await updateGlobalState(context, "geminiBaseUrl", geminiBaseUrl)
await updateGlobalState(context, "azureApiVersion", azureApiVersion)
await updateGlobalState(context, "openRouterProviderSorting", openRouterProviderSorting)
await updateGlobalState(context, "liteLlmBaseUrl", liteLlmBaseUrl)
await updateGlobalState(context, "liteLlmUsePromptCache", liteLlmUsePromptCache)
await updateGlobalState(context, "qwenApiLine", qwenApiLine)
await updateGlobalState(context, "asksageApiUrl", asksageApiUrl)
await updateGlobalState(context, "favoritedModelIds", favoritedModelIds)
await updateGlobalState(context, "requestTimeoutMs", apiConfiguration.requestTimeoutMs)
await updateGlobalState(context, "fireworksModelMaxCompletionTokens", fireworksModelMaxCompletionTokens)
await updateGlobalState(context, "fireworksModelMaxTokens", fireworksModelMaxTokens)
await updateGlobalState(context, "sapAiCoreBaseUrl", sapAiCoreBaseUrl)
await updateGlobalState(context, "sapAiCoreTokenUrl", sapAiCoreTokenUrl)
await updateGlobalState(context, "sapAiResourceGroup", sapAiResourceGroup)
await updateGlobalState(context, "claudeCodePath", claudeCodePath)
// Global state updates (27 keys)
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiHeaders: openAiHeaders || {},
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiBaseUrl,
azureApiVersion,
openRouterProviderSorting,
liteLlmBaseUrl,
liteLlmUsePromptCache,
qwenApiLine,
asksageApiUrl,
favoritedModelIds,
requestTimeoutMs: apiConfiguration.requestTimeoutMs,
fireworksModelMaxCompletionTokens,
fireworksModelMaxTokens,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
}
// Secret updates
await storeSecret(context, "apiKey", apiKey)
await storeSecret(context, "openRouterApiKey", openRouterApiKey)
await storeSecret(context, "clineApiKey", clineApiKey)
await storeSecret(context, "awsAccessKey", awsAccessKey)
await storeSecret(context, "awsSecretKey", awsSecretKey)
await storeSecret(context, "awsSessionToken", awsSessionToken)
await storeSecret(context, "openAiApiKey", openAiApiKey)
await storeSecret(context, "geminiApiKey", geminiApiKey)
await storeSecret(context, "openAiNativeApiKey", openAiNativeApiKey)
await storeSecret(context, "deepSeekApiKey", deepSeekApiKey)
await storeSecret(context, "requestyApiKey", requestyApiKey)
await storeSecret(context, "togetherApiKey", togetherApiKey)
await storeSecret(context, "qwenApiKey", qwenApiKey)
await storeSecret(context, "doubaoApiKey", doubaoApiKey)
await storeSecret(context, "mistralApiKey", mistralApiKey)
await storeSecret(context, "liteLlmApiKey", liteLlmApiKey)
await storeSecret(context, "fireworksApiKey", fireworksApiKey)
await storeSecret(context, "asksageApiKey", asksageApiKey)
await storeSecret(context, "xaiApiKey", xaiApiKey)
await storeSecret(context, "sambanovaApiKey", sambanovaApiKey)
await storeSecret(context, "cerebrasApiKey", cerebrasApiKey)
await storeSecret(context, "nebiusApiKey", nebiusApiKey)
await storeSecret(context, "sapAiCoreClientId", sapAiCoreClientId)
await storeSecret(context, "sapAiCoreClientSecret", sapAiCoreClientSecret)
// OPTIMIZED: Batch all secret updates into 1 operation instead of 23
const batchedSecretUpdates = {
apiKey,
openRouterApiKey,
clineAccountId,
awsAccessKey,
awsSecretKey,
awsSessionToken,
openAiApiKey,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
togetherApiKey,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
liteLlmApiKey,
fireworksApiKey,
asksageApiKey,
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
nebiusApiKey,
sapAiCoreClientId,
sapAiCoreClientSecret,
}
// Execute batched operations in parallel for maximum performance
await Promise.all([updateGlobalStateBatch(context, batchedGlobalUpdates), updateSecretsBatch(context, batchedSecretUpdates)])
}
export async function resetWorkspaceState(context: vscode.ExtensionContext) {
@@ -597,7 +667,7 @@ export async function resetGlobalState(context: vscode.ExtensionContext) {
"qwenApiKey",
"doubaoApiKey",
"mistralApiKey",
"clineApiKey",
"clineAccountId",
"liteLlmApiKey",
"fireworksApiKey",
"asksageApiKey",
+14
View File
@@ -2124,6 +2124,13 @@ export class Task {
this.api.getModel().id,
"assistant",
true,
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
)
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
@@ -2297,6 +2304,13 @@ export class Task {
this.api.getModel().id,
"assistant",
true,
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
)
await this.messageStateHandler.addToApiConversationHistory({
+11 -6
View File
@@ -35,6 +35,7 @@ import * as hostProviders from "@hosts/host-providers"
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"
/*
@@ -293,24 +294,28 @@ export async function activate(context: vscode.ExtensionContext) {
break
}
case "/auth": {
const token = query.get("token")
const authService = AuthService.getInstance()
console.log("Auth callback received:", uri.toString())
const token = query.get("idToken")
const state = query.get("state")
const apiKey = query.get("apiKey")
const provider = query.get("provider")
console.log("Auth callback received:", {
token: token,
state: state,
apiKey: apiKey,
provider: provider,
})
// Validate state parameter
if (!(await visibleWebview?.controller.validateAuthState(state))) {
if (!(authService.authNonce === state)) {
vscode.window.showErrorMessage("Invalid auth state")
return
}
if (token && apiKey) {
await visibleWebview?.controller.handleAuthCallback(token, apiKey)
if (token) {
await visibleWebview?.controller.handleAuthCallback(token, provider)
// await authService.handleAuthCallback(token)
}
break
}
-2
View File
@@ -1,5 +1,4 @@
import {
UriServiceClientInterface,
WatchServiceClientInterface,
WorkspaceServiceClientInterface,
EnvServiceClientInterface,
@@ -10,7 +9,6 @@ import {
* Interface for host bridge client providers
*/
export interface HostBridgeClientProvider {
uriServiceClient: UriServiceClientInterface
watchServiceClient: WatchServiceClientInterface
workspaceClient: WorkspaceServiceClientInterface
envClient: EnvServiceClientInterface
-1
View File
@@ -1,7 +1,6 @@
import { WebviewProvider } from "@core/webview"
import { HostBridgeClientProvider } from "./host-provider-types"
import { WebviewProviderType } from "@/shared/webview/types"
import * as vscode from "vscode"
/**
* A function that creates WebviewProvider instances
@@ -3,7 +3,6 @@ import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
import * as host from "@shared/proto/index.host"
export const vscodeHostBridgeClient: HostBridgeClientProvider = {
uriServiceClient: createGrpcClient(host.UriServiceDefinition),
watchServiceClient: createGrpcClient(host.WatchServiceDefinition),
workspaceClient: createGrpcClient(host.WorkspaceServiceDefinition),
envClient: createGrpcClient(host.EnvServiceDefinition),
+8
View File
@@ -0,0 +1,8 @@
import { Empty, StringRequest } from "@/shared/proto/common"
import * as vscode from "vscode"
export async function openExternal(request: StringRequest): Promise<Empty> {
console.log("openExternal called with request:", request)
await vscode.env.openExternal(vscode.Uri.parse(request.value))
return Empty.create()
}
-20
View File
@@ -1,20 +0,0 @@
import * as vscode from "vscode"
import { Uri } from "@shared/proto/host/uri"
import { StringRequest } from "@shared/proto/common"
/**
* Creates a file URI from a file path
* @param request The request containing the file path
* @returns A URI object representing the file
*/
export async function file(request: StringRequest): Promise<Uri> {
const uri = vscode.Uri.file(request.value)
return Uri.create({
scheme: uri.scheme,
authority: uri.authority,
path: uri.path,
query: uri.query,
fragment: uri.fragment,
fsPath: uri.fsPath,
})
}
-28
View File
@@ -1,28 +0,0 @@
import * as vscode from "vscode"
import { JoinPathRequest, Uri } from "@shared/proto/host/uri"
/**
* Joins a URI with additional path segments
* @param request The request containing the base URI and path segments
* @returns A new URI with the path segments joined
*/
export async function joinPath(request: JoinPathRequest): Promise<Uri> {
// Convert proto Uri to vscode.Uri
if (!request.base) {
throw new Error("Base URI is required")
}
const baseUri = vscode.Uri.parse(`${request.base.scheme}://${request.base.authority}${request.base.path}`)
// Join paths
const result = vscode.Uri.joinPath(baseUri, ...request.pathSegments)
// Convert back to proto Uri
return Uri.create({
scheme: result.scheme,
authority: result.authority,
path: result.path,
query: result.query,
fragment: result.fragment,
fsPath: result.fsPath,
})
}
-20
View File
@@ -1,20 +0,0 @@
import * as vscode from "vscode"
import { Uri } from "@shared/proto/host/uri"
import { StringRequest } from "@shared/proto/common"
/**
* Parses a string URI into a Uri object
* @param request The request containing the URI string
* @returns A URI object representing the parsed URI
*/
export async function parse(request: StringRequest): Promise<Uri> {
const uri = vscode.Uri.parse(request.value)
return Uri.create({
scheme: uri.scheme,
authority: uri.authority,
path: uri.path,
query: uri.query,
fragment: uri.fragment,
fsPath: uri.fsPath,
})
}
+3 -4
View File
@@ -1,4 +1,5 @@
import * as vscode from "vscode"
import { openExternal } from "@utils/env"
/**
* Detects potential AI-generated code omissions in the given file content.
@@ -47,10 +48,8 @@ export function showOmissionWarning(originalFileContent: string, newFileContent:
)
.then((selection) => {
if (selection === "Follow this guide to fix the issue") {
vscode.env.openExternal(
vscode.Uri.parse(
"https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
),
openExternal(
"https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
)
}
})
@@ -3,6 +3,7 @@ import * as path from "path"
import { listFiles } from "@services/glob/list-files"
import { sendWorkspaceUpdateEvent } from "@core/controller/file/subscribeToWorkspaceUpdates"
import { getCwd } from "@/utils/path"
import { isDirectory } from "@/utils/fs"
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
class WorkspaceTracker {
@@ -114,9 +115,8 @@ class WorkspaceTracker {
private async addFilePath(filePath: string): Promise<string> {
const normalizedPath = this.normalizeFilePath(filePath)
try {
const stat = await vscode.workspace.fs.stat(vscode.Uri.file(normalizedPath))
const isDirectory = (stat.type & vscode.FileType.Directory) !== 0
const pathWithSlash = isDirectory && !normalizedPath.endsWith("/") ? normalizedPath + "/" : normalizedPath
const isDir = await isDirectory(normalizedPath)
const pathWithSlash = isDir && !normalizedPath.endsWith("/") ? normalizedPath + "/" : normalizedPath
this.filePaths.add(pathWithSlash)
return pathWithSlash
} catch {
+180 -22
View File
@@ -1,13 +1,43 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "@shared/ClineAccount"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import type {
BalanceResponse,
OrganizationBalanceResponse,
OrganizationUsageTransaction,
PaymentTransaction,
UsageTransaction,
UserResponse,
} from "@shared/ClineAccount"
import { AuthService } from "../auth/AuthService"
export class ClineAccountService {
private readonly baseUrl = "https://api.cline.bot/v1"
private getClineApiKey: () => Promise<string | undefined>
private static instance: ClineAccountService
private _authService: AuthService
// TODO: replace this with a global API Host
private readonly _baseUrl = "https://api.cline.bot"
// private readonly _baseUrl = "https://core-api.staging.int.cline.bot"
// private readonly _baseUrl = "http://localhost:7777"
constructor(getClineApiKey: () => Promise<string | undefined>) {
this.getClineApiKey = getClineApiKey
constructor() {
this._authService = AuthService.getInstance()
}
/**
* Returns the singleton instance of ClineAccountService
* @returns Singleton instance of ClineAccountService
*/
public static getInstance(): ClineAccountService {
if (!ClineAccountService.instance) {
ClineAccountService.instance = new ClineAccountService()
}
return ClineAccountService.instance
}
/**
* Returns the base URL for the Cline API
* @returns The base URL as a string
*/
get baseUrl(): string {
return this._baseUrl
}
/**
@@ -18,29 +48,38 @@ export class ClineAccountService {
* @throws Error if the API key is not found or the request fails
*/
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
const clineApiKey = await this.getClineApiKey()
const url = `${this._baseUrl}${endpoint}`
if (!clineApiKey) {
throw new Error("Cline API key not found")
}
const clineAccountAuthToken = await this._authService.getAuthToken()
const url = `${this.baseUrl}${endpoint}`
const requestConfig: AxiosRequestConfig = {
...config,
headers: {
Authorization: `Bearer ${clineApiKey}`,
Authorization: `Bearer ${clineAccountAuthToken}`,
"Content-Type": "application/json",
...config.headers,
},
}
const response: AxiosResponse<T> = await axios.get(url, requestConfig)
if (!response.data) {
const response: AxiosResponse<{ data?: T; error: string; success: boolean }> = await axios.request({
url,
method: "GET",
...requestConfig,
})
const status = response.status
if (status < 200 || status >= 300) {
throw new Error(`Request to ${endpoint} failed with status ${status}`)
}
if (response.statusText !== "No Content" && (!response.data || !response.data.data)) {
throw new Error(`Invalid response from ${endpoint} API`)
}
return response.data
if (typeof response.data === "object" && !response.data.success) {
throw new Error(`API error: ${response.data.error}`)
}
if (response.statusText === "No Content") {
return {} as T // Return empty object if no content
} else {
return response.data.data as T
}
}
/**
@@ -49,7 +88,12 @@ export class ClineAccountService {
*/
async fetchBalanceRPC(): Promise<BalanceResponse | undefined> {
try {
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
const me = await this.fetchMe()
if (!me || !me.id) {
console.error("Failed to fetch user ID for usage transactions")
return undefined
}
const data = await this.authenticatedRequest<BalanceResponse>(`/api/v1/users/${me.id}/balance`)
return data
} catch (error) {
console.error("Failed to fetch balance (RPC):", error)
@@ -63,8 +107,13 @@ export class ClineAccountService {
*/
async fetchUsageTransactionsRPC(): Promise<UsageTransaction[] | undefined> {
try {
const data = await this.authenticatedRequest<{ usageTransactions: UsageTransaction[] }>("/user/credits/usage")
return data.usageTransactions
const me = await this.fetchMe()
if (!me || !me.id) {
console.error("Failed to fetch user ID for usage transactions")
return undefined
}
const data = await this.authenticatedRequest<{ items: UsageTransaction[] }>(`/api/v1/users/${me.id}/usages`)
return data.items
} catch (error) {
console.error("Failed to fetch usage transactions (RPC):", error)
return undefined
@@ -77,11 +126,120 @@ export class ClineAccountService {
*/
async fetchPaymentTransactionsRPC(): Promise<PaymentTransaction[] | undefined> {
try {
const data = await this.authenticatedRequest<{ paymentTransactions: PaymentTransaction[] }>("/user/credits/payments")
const me = await this.fetchMe()
if (!me || !me.id) {
console.error("Failed to fetch user ID for usage transactions")
return undefined
}
const data = await this.authenticatedRequest<{ paymentTransactions: PaymentTransaction[] }>(
`/api/v1/users/${me.id}/payments`,
)
return data.paymentTransactions
} catch (error) {
console.error("Failed to fetch payment transactions (RPC):", error)
return undefined
}
}
/**
* Fetches the current user data
* @returns UserResponse or undefined if failed
*/
async fetchMe(): Promise<UserResponse | undefined> {
try {
const data = await this.authenticatedRequest<UserResponse>(`/api/v1/users/me`)
return data
} catch (error) {
console.error("Failed to fetch user data (RPC):", error)
return undefined
}
}
/**
* Fetches the current user's organizations
* @returns UserResponse["organizations"] or undefined if failed
*/
async fetchUserOrganizationsRPC(): Promise<UserResponse["organizations"] | undefined> {
try {
const me = await this.fetchMe()
if (!me || !me.organizations) {
console.error("Failed to fetch user organizations")
return undefined
}
return me.organizations
} catch (error) {
console.error("Failed to fetch user organizations (RPC):", error)
return undefined
}
}
/**
* Fetches the current user's organization credits
* @returns {Promise<OrganizationBalanceResponse>} A promise that resolves to the active organization balance.
*/
async fetchOrganizationCreditsRPC(organizationId: string): Promise<OrganizationBalanceResponse | undefined> {
try {
const data = await this.authenticatedRequest<OrganizationBalanceResponse>(
`/api/v1/organizations/${organizationId}/balance`,
)
return data
} catch (error) {
console.error("Failed to fetch active organization balance (RPC):", error)
return undefined
}
}
/**
* Fetches the current user's organization transactions
* @returns {Promise<OrganizationUsageTransaction[]>} A promise that resolves to the active organization transactions.
*/
async fetchOrganizationUsageTransactionsRPC(organizationId: string): Promise<OrganizationUsageTransaction[] | undefined> {
try {
const me = await this.fetchMe()
if (!me || !me.id) {
console.error("Failed to fetch user ID for active organization transactions")
return undefined
}
const memberId = me.organizations.find((org) => org.organizationId === organizationId)?.memberId
if (!memberId) {
console.error("Failed to find member ID for active organization transactions")
return undefined
}
const data = await this.authenticatedRequest<{ items: OrganizationUsageTransaction[] }>(
`/api/v1/organizations/${organizationId}/members/${memberId}/usages`,
)
return data.items
} catch (error) {
console.error("Failed to fetch active organization transactions (RPC):", error)
return undefined
}
}
/**
* Switches the active account to the specified organization or personal account.
* @param organizationId - Optional organization ID to switch to. If not provided, it will switch to the personal account.
* @returns {Promise<void>} A promise that resolves when the account switch is complete.
* @throws {Error} If the account switch fails, an error will be thrown.
*/
async switchAccount(organizationId?: string): Promise<void> {
// Call API to switch account
try {
// make XHR request to switch account
const response = await this.authenticatedRequest<string>(`/api/v1/users/active-account`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: {
organizationId: organizationId || null, // Pass organization if provided
},
})
} catch (error) {
console.error("Error switching account:", error)
throw error
} finally {
// Request a new authentication token
await this._authService.refreshAuth()
}
}
}
+364
View File
@@ -0,0 +1,364 @@
import vscode from "vscode"
import crypto from "crypto"
import { EmptyRequest, String } from "../../shared/proto/common"
import { AuthState } from "../../shared/proto/account"
import { StreamingResponseHandler, getRequestRegistry } from "@/core/controller/grpc-handler"
import { FirebaseAuthProvider } from "./providers/FirebaseAuthProvider"
import { Controller } from "@/core/controller"
import { storeSecret } from "@/core/storage/state"
const DefaultClineAccountURI = "https://app.cline.bot/auth"
// const DefaultClineAccountURI = "https://staging-app.cline.bot/auth"
// const DefaultClineAccountURI = "http://localhost:3000/auth"
let authProviders: any[] = []
type ServiceConfig = {
URI?: string
[key: string]: any
}
const availableAuthProviders = {
firebase: FirebaseAuthProvider,
// Add other providers here as needed
}
// TODO: Add logic to handle multiple webviews getting auth updates.
export class AuthService {
private static instance: AuthService | null = null
private _config: ServiceConfig
private _authenticated: boolean = false
private _user: any = null
private _provider: any = null
private _authNonce: string | null = null
private _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler]>()
private _context: vscode.ExtensionContext
/**
* Creates an instance of AuthService.
* @param config - Configuration for the service, including the URI for authentication.
* @param authProvider - Optional authentication provider to use.
* @param controller - Optional reference to the Controller instance.
*/
private constructor(context: vscode.ExtensionContext, config: ServiceConfig, authProvider?: any) {
const providerName = authProvider || "firebase"
this._config = Object.assign({ URI: DefaultClineAccountURI }, config)
// Fetch AuthProviders
// TODO: Deliver this config from the backend securely
// ex. https://app.cline.bot/api/v1/auth/providers
const authProvidersConfigs = [
{
name: "firebase",
config: {
apiKey: "AIzaSyC5rx59Xt8UgwdU3PCfzUF7vCwmp9-K2vk",
authDomain: "cline-prod.firebaseapp.com",
projectId: "cline-prod",
storageBucket: "cline-prod.firebasestorage.app",
messagingSenderId: "941048379330",
appId: "1:941048379330:web:45058eedeefc5cdfcc485b",
},
// Uncomment for staging environment
// config: {
// apiKey: "AIzaSyASSwkwX1kSO8vddjZkE5N19QU9cVQ0CIk",
// authDomain: "cline-staging.firebaseapp.com",
// projectId: "cline-staging",
// storageBucket: "cline-staging.firebasestorage.app",
// messagingSenderId: "853479478430",
// appId: "1:853479478430:web:2de0dba1c63c3262d4578f",
// },
// Uncomment for local development environment
// config: {
// apiKey: "AIzaSyASSwkwX1kSO8vddjZkE5N19QU9cVQ0CIk",
// authDomain: "cline-staging.firebaseapp.com",
// projectId: "cline-staging",
// storageBucket: "cline-staging.firebasestorage.app",
// messagingSenderId: "853479478430",
// appId: "1:853479478430:web:2de0dba1c63c3262d4578f",
// },
// config: {
// apiKey: "AIzaSyD8wtkd1I-EICuAg6xgAQpRdwYTvwxZG2w",
// authDomain: "cline-preview.firebaseapp.com",
// projectId: "cline-preview",
// }
},
]
// Merge authProviders with availableAuthProviders
authProviders = authProvidersConfigs.map((provider) => {
const providerName = provider.name
const ProviderClass = availableAuthProviders[providerName as keyof typeof availableAuthProviders]
if (!ProviderClass) {
throw new Error(`Auth provider "${providerName}" is not available`)
}
return {
name: providerName,
config: provider.config,
provider: new ProviderClass(provider.config),
}
})
this._setProvider(authProviders.find((authProvider) => authProvider.name === providerName).name)
this._context = context
}
/**
* Gets the singleton instance of AuthService.
* @param config - Configuration for the service, including the URI for authentication.
* @param authProvider - Optional authentication provider to use.
* @param controller - Optional reference to the Controller instance.
* @returns The singleton instance of AuthService.
*/
public static getInstance(context?: vscode.ExtensionContext, config?: ServiceConfig, authProvider?: any): AuthService {
if (!AuthService.instance) {
if (!context) {
console.warn("Extension context was not provided to AuthService.getInstance, using default context")
context = {} as vscode.ExtensionContext
}
AuthService.instance = new AuthService(context, config || {}, authProvider)
}
if (context) {
AuthService.instance.context = context
}
return AuthService.instance
}
set context(context: vscode.ExtensionContext) {
this._context = context
}
get authProvider(): any {
return this._provider
}
set authProvider(providerName: string) {
this._setProvider(providerName)
}
get authNonce(): string | null {
return this._authNonce
}
async getAuthToken(): Promise<string | null> {
if (!this._user) {
return null
}
// TODO: This may need to be dependant on the auth provider
// Return the ID token from the user object
return this._provider.provider.getAuthToken(this._user)
}
private _setProvider(providerName: string): void {
const providerConfig = authProviders.find((provider) => provider.name === providerName)
if (!providerConfig) {
throw new Error(`Auth provider "${providerName}" not found`)
}
this._provider = providerConfig
}
getInfo(): AuthState {
let user = null
if (this._user && this._authenticated) {
user = this._provider.provider.convertUserData(this._user)
}
return AuthState.create({
user: user,
})
}
/**
* Resets the auth nonce to null.
* This is typically called after a successful authentication.
*/
resetAuthNonce(): void {
this._authNonce = null
}
async createAuthRequest(): Promise<String> {
if (!this._authenticated) {
// Generate nonce for state validation
this._authNonce = crypto.randomBytes(32).toString("hex")
const uriScheme = vscode.env.uriScheme
const authUrl = vscode.Uri.parse(
`${this._config.URI}?state=${encodeURIComponent(this._authNonce)}&callback_url=${encodeURIComponent(`${uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`)}`,
)
await vscode.env.openExternal(authUrl)
return String.create({
value: authUrl.toString(),
})
} else {
this.sendAuthStatusUpdate()
return String.create({
value: "Already authenticated",
})
}
}
async handleDeauth(): Promise<void> {
if (!this._provider) {
throw new Error("Auth provider is not set")
}
try {
await this._provider.provider.signOut()
this._user = null
this._authenticated = false
this.sendAuthStatusUpdate()
} catch (error) {
console.error("Error signing out:", error)
throw error
}
}
async handleAuthCallback(token: string, provider: string): Promise<void> {
if (!this._provider) {
throw new Error("Auth provider is not set")
}
try {
this._user = await this._provider.provider.signIn(this._context, token, provider)
this._authenticated = true
await this.sendAuthStatusUpdate()
this.setupAutoRefreshAuth()
return this._user
} catch (error) {
console.error("Error signing in with custom token:", error)
throw error
}
}
/**
* Clear the authentication token from the extension's storage.
* This is typically called when the user logs out.
*/
async clearAuthToken(): Promise<void> {
await storeSecret(this._context, "clineAccountId", undefined)
}
/**
* Restores the authentication token from the extension's storage.
* This is typically called when the extension is activated.
*/
async restoreAuthToken(): Promise<void> {
if (!this._provider || !this._provider.provider) {
throw new Error("Auth provider is not set")
}
try {
this._user = await this._provider.provider.restoreAuthCredential(this._context)
if (this._user) {
this._authenticated = true
await this.sendAuthStatusUpdate()
this.setupAutoRefreshAuth()
// Setup auto-refresh for the auth token
} else {
console.warn("No user found after restoring auth token")
this._authenticated = false
this._user = null
}
} catch (error) {
console.error("Error restoring auth token:", error)
this._authenticated = false
this._user = null
return
}
}
/**
* Refreshes the authentication status and sends an update to all subscribers.
*/
async refreshAuth(): Promise<void> {
if (!this._user) {
console.warn("No user is authenticated, skipping auth refresh")
return
}
await this._provider.provider.refreshAuthToken()
this.sendAuthStatusUpdate()
}
private setupAutoRefreshAuth(): void {
// Set timeoutDuration to refresh the auth token 5 minutes before it expires
const timeoutDuration = Math.floor(this._user.stsTokenManager.expirationTime - 5 * 60000 - Date.now()) // Milliseconds until 5 minutes before expiration
setTimeout(() => this._autoRefreshAuth(), timeoutDuration)
}
private async _autoRefreshAuth(): Promise<void> {
if (!this._user) {
console.warn("No user is authenticated, skipping auth refresh")
return
}
await this.refreshAuth()
this.setupAutoRefreshAuth() // Reschedule the next auto-refresh
}
/**
* Subscribe to authStatusUpdate events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
async subscribeToAuthStatusUpdate(
controller: Controller,
request: EmptyRequest,
responseStream: StreamingResponseHandler,
requestId?: string,
): Promise<void> {
console.log("Subscribing to authStatusUpdate")
// Add this subscription to the active subscriptions
this._activeAuthStatusUpdateSubscriptions.add([controller, responseStream])
// Register cleanup when the connection is closed
const cleanup = () => {
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "authStatusUpdate_subscription" }, responseStream)
}
// Send the current authentication status immediately
try {
await this.sendAuthStatusUpdate()
} catch (error) {
console.error("Error sending initial auth status:", error)
// Remove the subscription if there was an error
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
}
}
/**
* Send an authStatusUpdate event to all active subscribers
*/
async sendAuthStatusUpdate(): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(this._activeAuthStatusUpdateSubscriptions).map(async ([controller, responseStream]) => {
try {
const authInfo: AuthState = this.getInfo()
await responseStream(
authInfo,
false, // Not the last message
)
// Update the state in the webview
if (controller) {
await controller.postStateToWebview()
}
} catch (error) {
console.error("Error sending authStatusUpdate event:", error)
// Remove the subscription if there was an error
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
}
})
await Promise.all(promises)
}
}
-10
View File
@@ -1,10 +0,0 @@
// Public Firebase config (safe for open source)
export const firebaseConfig = {
apiKey: "AIzaSyDcXAaanNgR2_T0dq2oOl5XyKPksYHppVo",
authDomain: "cline-bot.firebaseapp.com",
projectId: "cline-bot",
storageBucket: "cline-bot.firebasestorage.app",
messagingSenderId: "364369702101",
appId: "1:364369702101:web:0013885dcf20b43799c65c",
measurementId: "G-MDPRELSCD1",
}
@@ -0,0 +1,172 @@
import { getSecret, storeSecret } from "@/core/storage/state"
import { ErrorService } from "@/services/error/ErrorService"
import { initializeApp } from "firebase/app"
import {
AuthCredential,
GoogleAuthProvider,
GithubAuthProvider,
OAuthCredential,
User,
UserCredential,
getAuth,
signInWithCredential,
signOut,
} from "firebase/auth"
import { ExtensionContext } from "vscode"
export class FirebaseAuthProvider {
private _config: any
constructor(config: any) {
this._config = config || {}
}
get config(): any {
return this._config
}
set config(value: any) {
this._config = value
}
/**
* Gets the authentication token of the current user.
* @returns {Promise<string | null>} A promise that resolves to the authentication token of the current user, or null if no user is signed in.
*/
async getAuthToken(): Promise<string | null> {
const user = getAuth().currentUser
const idToken = user ? await user.getIdToken() : null
return idToken
}
/**
* Gets the refresh token of the current user.
* @returns {Promise<string | null>} A promise that resolves to the refresh token of the current user, or null if no user is signed in.
*/
async getRefreshToken(): Promise<string | null> {
const user = getAuth().currentUser
const refreshToken = user ? user.refreshToken : null
return refreshToken
}
/**
* Refreshes the authentication token of the current user.
* @returns {Promise<string | null>} A promise that resolves to the refreshed authentication token of the current user, or null if no user is signed in.
*/
async refreshAuthToken(): Promise<string | null> {
const user = getAuth().currentUser
const idToken = user ? await user.getIdToken(true) : null
return idToken
}
/**
* Converts Firebase User object to a generic user object.
* @param user - The Firebase User object.
* @returns {User} A generic user object.
*/
convertUserData(user: User) {
return {
uid: user.uid,
email: user.email,
displayName: user.displayName,
photoUrl: user.photoURL,
}
}
/**
* Signs out the current user from Firebase.
* @returns {Promise<void>} A promise that resolves when the user is signed out.
*/
async signOut(): Promise<void> {
signOut(getAuth(initializeApp(Object.assign({}, this._config))))
.then(() => {
console.log("User signed out successfully.")
})
.catch((error) => {
ErrorService.logMessage("Firebase sign-out error", "error")
ErrorService.logException(error)
throw error
})
}
/**
* Stores the authentication token using a provided token.
* @param token - The authentication token to store.
* @returns {Promise<User>} A promise that resolves with the authenticated user.
* @throws {Error} Throws an error if the storage fails.
*/
private async _storeAuthCredential(context: ExtensionContext, credential: AuthCredential): Promise<void> {
try {
await storeSecret(context, "clineAccountId", JSON.stringify(credential.toJSON()))
} catch (error) {
ErrorService.logMessage("Firebase store token error", "error")
ErrorService.logException(error)
throw error
}
}
/**
* Restores the authentication token using a provided token.
* @param token - The authentication token to restore.
* @returns {Promise<User>} A promise that resolves with the authenticated user.
* @throws {Error} Throws an error if the restoration fails.
*/
async restoreAuthCredential(context: ExtensionContext): Promise<User | null> {
const credentialJSON = await getSecret(context, "clineAccountId")
if (!credentialJSON) {
console.error("No stored authentication credential found.")
return null
}
try {
const credentialData: AuthCredential = OAuthCredential.fromJSON(credentialJSON) as AuthCredential
const userCredential = await this._signInWithCredential(credentialData)
return userCredential.user
} catch (error) {
ErrorService.logMessage("Firebase restore token error", "error")
ErrorService.logException(error)
throw error
}
}
async _signInWithCredential(credential: AuthCredential): Promise<UserCredential> {
const firebaseConfig = Object.assign({}, this._config)
const app = initializeApp(firebaseConfig)
const auth = getAuth(app)
try {
return await signInWithCredential(auth, credential)
} catch (error) {
ErrorService.logMessage("Firebase sign-in with credential error", "error")
ErrorService.logException(error)
throw error
}
}
/**
* Signs in the user using Firebase authentication with a custom token.
* @returns {Promise<User>} A promise that resolves with the authenticated user.
* @throws {Error} Throws an error if the sign-in fails.
*/
async signIn(context: ExtensionContext, token: string, provider: string): Promise<User> {
try {
let credential
let userCredential
switch (provider) {
case "google":
credential = GoogleAuthProvider.credential(token)
break
case "github":
credential = GithubAuthProvider.credential(token)
break
default:
throw new Error(`Unsupported provider: ${provider}`)
}
this._storeAuthCredential(context, credential)
userCredential = await this._signInWithCredential(credential)
return userCredential.user
} catch (error) {
ErrorService.logMessage("Firebase sign-in error", "error")
ErrorService.logException(error)
throw error
}
}
}
@@ -202,26 +202,23 @@ class TelemetryService {
const propertiesWithVersion = this.addProperties(event.properties)
const capturedEvent = {
event: event.event,
properties: propertiesWithVersion,
}
if (collect && taskId) {
const existingTask = this.collectedTasks.find((task) => task.taskId === taskId)
if (existingTask) {
existingTask.collection.push({
event: event.event,
properties: propertiesWithVersion,
})
existingTask.collection.push(capturedEvent)
} else {
this.collectedTasks.push({
taskId,
collection: [
{
event: event.event,
properties: propertiesWithVersion,
},
],
collection: [capturedEvent],
})
}
} else {
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: propertiesWithVersion })
this.client.capture({ ...capturedEvent, distinctId: this.distinctId })
}
}
@@ -288,6 +285,8 @@ class TelemetryService {
* @param provider The API provider (e.g., OpenAI, Anthropic)
* @param model The specific model used (e.g., GPT-4, Claude)
* @param source The source of the message ("user" | "model"). Used to track message patterns and identify when users need to correct the model's responses.
* @param collect If true, collect event instead of sending
* @param tokenUsage Optional token usage data
*/
public captureConversationTurnEvent(
taskId: string,
@@ -295,6 +294,13 @@ class TelemetryService {
model: string = "unknown",
source: "user" | "assistant",
collect: boolean = false,
tokenUsage: {
tokensIn?: number
tokensOut?: number
cacheWriteTokens?: number
cacheReadTokens?: number
totalCost?: number
} = {},
) {
// Ensure required parameters are provided
if (!taskId || !provider || !model || !source) {
@@ -308,6 +314,7 @@ class TelemetryService {
model,
source,
timestamp: new Date().toISOString(), // Add timestamp for message sequencing
...tokenUsage,
}
this.capture(
+3 -2
View File
@@ -252,6 +252,7 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
// Clear any existing task
await visibleWebview.controller.clearTask()
// TODO: convert apiKey to clineAccountId
// If API key is provided, update the API configuration
if (apiKey) {
Logger.log("API key provided, updating API configuration")
@@ -263,11 +264,11 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
const updatedConfig = {
...apiConfiguration,
apiProvider: "cline" as ApiProvider,
clineApiKey: apiKey,
clineAccountId: apiKey,
}
// Store the API key securely
await storeSecret(visibleWebview.controller.context, "clineApiKey", apiKey)
await storeSecret(visibleWebview.controller.context, "clineAccountId", apiKey)
// Update the API configuration
await updateApiConfiguration(visibleWebview.controller.context, updatedConfig)
+64 -7
View File
@@ -1,16 +1,45 @@
export interface UserResponse {
id: string
email: string
displayName: string
photoUrl: string
createdAt: string
updatedAt: string
organizations: [
{
active: boolean
memberId: string
name: string
organizationId: string
roles: ["admin" | "member" | "owner"]
},
]
}
export interface BalanceResponse {
currentBalance: number
balance: number
userId: string
}
export interface UsageTransaction {
spentAt: string
creatorId: string
credits: number
modelProvider: string
model: string
promptTokens: number
aiInferenceProviderName: string
aiModelName: string
aiModelTypeName: string
completionTokens: number
costUsd: number
createdAt: string
creditsUsed: number
generationId: string
id: string
metadata: {
additionalProp1: string
additionalProp2: string
additionalProp3: string
}
organizationId: string
promptTokens: number
totalTokens: number
userId: string
}
export interface PaymentTransaction {
@@ -19,3 +48,31 @@ export interface PaymentTransaction {
amountCents: number
credits: number
}
export interface OrganizationBalanceResponse {
balance: number
organizationId: string
}
export interface OrganizationUsageTransaction {
aiInferenceProviderName: string
aiModelName: string
aiModelTypeName: string
completionTokens: number
costUsd: number
createdAt: string
creditsUsed: number
generationId: string
id: string
memberDisplayName: string
memberEmail: string
metadata: {
additionalProp1: string
additionalProp2: string
additionalProp3: string
}
organizationId: string
promptTokens: number
totalTokens: number
userId: string
}
+3 -36
View File
@@ -1,50 +1,17 @@
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello'
import { GitCommit } from "../utils/git"
import { ApiConfiguration, ModelInfo } from "./api"
import { ApiConfiguration } from "./api"
import { AutoApprovalSettings } from "./AutoApprovalSettings"
import { BrowserSettings } from "./BrowserSettings"
import { ChatSettings } from "./ChatSettings"
import { HistoryItem } from "./HistoryItem"
import { McpServer, McpMarketplaceCatalog, McpDownloadResponse, McpViewTab } from "./mcp"
import { TelemetrySetting } from "./TelemetrySetting"
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
import { ClineRulesToggles } from "./cline-rules"
import { UserInfo } from "./UserInfo"
// webview will hold state
export interface ExtensionMessage {
type: "action" | "state" | "selectedImages" | "mcpDownloadDetails" | "grpc_response" // New type for gRPC responses
text?: string
action?: "accountLogoutClicked"
state?: ExtensionState
images?: string[]
files?: string[]
ollamaModels?: string[]
lmStudioModels?: string[]
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
openAiModels?: string[]
mcpServers?: McpServer[]
customToken?: string
mcpMarketplaceCatalog?: McpMarketplaceCatalog
error?: string
mcpDownloadDetails?: McpDownloadResponse
commits?: GitCommit[]
url?: string
isImage?: boolean
success?: boolean
endpoint?: string
isBundled?: boolean
isConnected?: boolean
isRemote?: boolean
host?: string
mentionsRequestId?: string
results?: Array<{
path: string
type: "file" | "folder"
label?: string
}>
tab?: McpViewTab
type: "grpc_response" // New type for gRPC responses
grpc_response?: {
message?: any // JSON serialized protobuf message
request_id: string // Same ID as the request
+1 -1
View File
@@ -1,5 +1,5 @@
export interface UserInfo {
displayName?: string
email?: string
photoURL?: string
photoUrl?: string
}
+46 -83
View File
@@ -31,7 +31,7 @@ export type ApiProvider =
export interface ApiHandlerOptions {
apiModelId?: string
apiKey?: string // anthropic
clineApiKey?: string
clineAccountId?: string
taskId?: string // Used to identify the task in API requests
liteLlmBaseUrl?: string
liteLlmModelId?: string
@@ -2373,173 +2373,136 @@ export const requestyDefaultModelInfo: ModelInfo = {
// SAP AI Core
export type SapAiCoreModelId = keyof typeof sapAiCoreModels
export const sapAiCoreDefaultModelId: SapAiCoreModelId = "anthropic--claude-3.5-sonnet"
// Pricing is calculated using Capacity Units, not directly in USD
const sapAiCoreModelDescription = "Pricing is calculated using SAP's Capacity Units rather than direct USD pricing."
export const sapAiCoreModels = {
"gemini-2.5-pro": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.5,
outputPrice: 15,
cacheReadsPrice: 0.625,
tiers: [
{
contextWindow: 200000,
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.31,
},
{
contextWindow: Infinity,
inputPrice: 2.5,
outputPrice: 15,
cacheReadsPrice: 0.625,
},
],
},
"gemini-2.5-flash": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.3,
outputPrice: 2.5,
cacheReadsPrice: 0.075,
thinkingConfig: {
maxBudget: 24576,
outputPrice: 3.5,
},
},
"anthropic--claude-4-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"anthropic--claude-4-opus": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"anthropic--claude-3.7-sonnet": {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"anthropic--claude-3.5-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"anthropic--claude-3-sonnet": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"anthropic--claude-3-haiku": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"anthropic--claude-3-opus": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"gpt-4o": {
maxTokens: 4096,
contextWindow: 200_000,
"gemini-2.5-pro": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"gpt-4o-mini": {
maxTokens: 4096,
contextWindow: 200_000,
"gemini-2.5-flash": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
supportsPromptCache: true,
thinkingConfig: {
maxBudget: 24576,
},
description: sapAiCoreModelDescription,
},
"gpt-4": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
o1: {
"gpt-4o": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"o3-mini": {
"gpt-4o-mini": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: sapAiCoreModelDescription,
},
"gpt-4.1": {
maxTokens: 32_768,
contextWindow: 1_047_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2,
outputPrice: 8,
cacheReadsPrice: 0.5,
description: sapAiCoreModelDescription,
},
"gpt-4.1-nano": {
maxTokens: 32_768,
contextWindow: 1_047_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.1,
outputPrice: 0.4,
cacheReadsPrice: 0.025,
description: sapAiCoreModelDescription,
},
o1: {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
description: sapAiCoreModelDescription,
},
o3: {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 10.0,
outputPrice: 40.0,
cacheReadsPrice: 2.5,
description: sapAiCoreModelDescription,
},
"o3-mini": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
description: sapAiCoreModelDescription,
},
"o4-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.275,
description: sapAiCoreModelDescription,
},
} as const satisfies Record<string, ModelInfo>
@@ -308,7 +308,7 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
return {
apiModelId: config.apiModelId,
apiKey: config.apiKey,
clineApiKey: config.clineApiKey,
clineAccountId: config.clineAccountId,
taskId: config.taskId,
liteLlmBaseUrl: config.liteLlmBaseUrl,
liteLlmModelId: config.liteLlmModelId,
@@ -387,7 +387,7 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
return {
apiModelId: protoConfig.apiModelId,
apiKey: protoConfig.apiKey,
clineApiKey: protoConfig.clineApiKey,
clineAccountId: protoConfig.clineAccountId,
taskId: protoConfig.taskId,
liteLlmBaseUrl: protoConfig.liteLlmBaseUrl,
liteLlmModelId: protoConfig.liteLlmModelId,
@@ -17,7 +17,7 @@ export function convertApiConfigurationToProtoApiConfiguration(config: ApiConfig
apiKey: config.apiKey,
// Provider-specific API keys
clineApiKey: config.clineApiKey,
clineAccountId: config.clineAccountId,
openrouterApiKey: config.openRouterApiKey,
anthropicBaseUrl: config.anthropicBaseUrl,
openaiApiKey: config.openAiApiKey,
@@ -137,7 +137,7 @@ export function convertProtoApiConfigurationToApiConfiguration(protoConfig: Prot
apiKey: protoConfig.apiKey,
// Provider-specific API keys
clineApiKey: protoConfig.clineApiKey,
clineAccountId: protoConfig.clineAccountId,
openRouterApiKey: protoConfig.openrouterApiKey,
anthropicBaseUrl: protoConfig.anthropicBaseUrl,
openAiApiKey: protoConfig.openaiApiKey,
@@ -1,13 +1,11 @@
import { Channel, createChannel } from "nice-grpc"
import {
UriServiceClientImpl,
WatchServiceClientImpl,
WorkspaceServiceClientImpl,
EnvServiceClientImpl,
WindowServiceClientImpl,
} from "@generated/standalone/host-bridge-clients"
import {
UriServiceClientInterface,
WatchServiceClientInterface,
WorkspaceServiceClientInterface,
EnvServiceClientInterface,
@@ -21,7 +19,6 @@ import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
*/
export class ExternalHostBridgeClientManager implements HostBridgeClientProvider {
private channel: Channel
uriServiceClient: UriServiceClientInterface
watchServiceClient: WatchServiceClientInterface
workspaceClient: WorkspaceServiceClientInterface
envClient: EnvServiceClientInterface
@@ -31,7 +28,6 @@ export class ExternalHostBridgeClientManager implements HostBridgeClientProvider
const address = process.env.HOST_BRIDGE_ADDRESS || "localhost:50052"
this.channel = createChannel(address)
this.uriServiceClient = new UriServiceClientImpl(this.channel)
this.watchServiceClient = new WatchServiceClientImpl(this.channel)
this.workspaceClient = new WorkspaceServiceClientImpl(this.channel)
this.envClient = new EnvServiceClientImpl(this.channel)
-56
View File
@@ -1,56 +0,0 @@
const { expect } = require("chai")
const vscode = require("vscode")
describe("Extension Tests", function () {
this.timeout(60000) // Increased timeout for extension operations
let originalGetConfiguration
beforeEach(() => {
// Save original configuration
originalGetConfiguration = vscode.workspace.getConfiguration
// Setup mock configuration
const mockUpdate = async () => Promise.resolve()
const mockConfig = {
get: () => true,
update: mockUpdate,
}
vscode.workspace.getConfiguration = () => mockConfig
})
afterEach(() => {
// Restore original configuration
vscode.workspace.getConfiguration = originalGetConfiguration
})
it("should activate extension successfully", async () => {
// Get the extension
const extension = vscode.extensions.getExtension("saoudrizwan.claude-dev")
expect(extension).to.not.be.undefined
// Activate the extension if not already activated
if (!extension.isActive) {
await extension.activate()
}
expect(extension.isActive).to.be.true
})
it("should open sidebar view", async () => {
// Execute the command to open sidebar
await vscode.commands.executeCommand("cline.plusButtonClicked")
// Wait for sidebar to be visible
await new Promise((resolve) => setTimeout(resolve, 1000))
// Get all views
const views = vscode.window.visibleTextEditors
// Just verify the command executed without error
// The actual view verification is handled in the TypeScript tests
})
it("should handle basic commands", async () => {
// Test basic command execution
await vscode.commands.executeCommand("cline.historyButtonClicked")
// Success if no error thrown
})
})
-43
View File
@@ -1,43 +0,0 @@
const path = require("path")
const Mocha = require("mocha")
const glob = require("glob")
async function run() {
// Create the mocha test
const mocha = new Mocha({
ui: "bdd",
color: true,
timeout: 60000, // Increased timeout for extension operations
})
const testsRoot = path.resolve(__dirname, ".")
try {
// Find all test files
const files = await glob("*.test.js", { cwd: testsRoot })
// Add files to the test suite
files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)))
// Run the mocha test
return new Promise((resolve, reject) => {
try {
// Run the tests
mocha.run((failures) => {
if (failures > 0) {
reject(new Error(`${failures} tests failed.`))
} else {
resolve()
}
})
} catch (err) {
reject(err)
}
})
} catch (err) {
console.error("Failed to run tests:", err)
throw err
}
}
module.exports = { run }
+15
View File
@@ -30,3 +30,18 @@ export async function readTextFromClipboard(): Promise<string> {
throw new Error(`Failed to read from clipboard: ${errorMessage}`)
}
}
/**
* Opens an external URL in the default browser
* @param url The URL to open
* @returns Promise that resolves when the operation is complete
* @throws Error if the operation fails
*/
export async function openExternal(url: string): Promise<void> {
try {
await getHostBridgeProvider().envClient.openExternal(StringRequest.create({ value: url }))
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to open external URL: ${errorMessage}`)
}
}
+6 -7
View File
@@ -17,7 +17,7 @@ import * as vscode from "vscode"
import * as cp from "child_process"
import * as os from "os"
import * as util from "util"
import { writeTextToClipboard } from "@/utils/env"
import { writeTextToClipboard, openExternal } from "@/utils/env"
/**
* Creates a properly encoded GitHub issue URL.
@@ -141,16 +141,15 @@ export async function openUrlInBrowser(url: string): Promise<void> {
} catch (error) {
console.error(`OS commands failed: ${error}`)
// First fallback: Try VS Code's openExternal
// First fallback: Try openExternal utility
// Note: This will likely have encoding issues per https://github.com/microsoft/vscode/issues/85930
// but we include it as a fallback in case OS commands completely fail
try {
// The 'true' parameter might help preserve some encodings, but this is not guaranteed
await vscode.env.openExternal(vscode.Uri.parse(url, true))
console.log("Opened URL with vscode.env.openExternal (note: URL encoding may be affected)")
await openExternal(url)
console.log("Opened URL with openExternal utility (note: URL encoding may be affected)")
return
} catch (vscodeError) {
console.error(`Error with vscode.env.openExternal: ${vscodeError}`)
} catch (openExternalError) {
console.error(`Error with openExternal utility: ${openExternalError}`)
// Last fallback: Show a message with instructions
vscode.window
+54 -25
View File
@@ -1,7 +1,7 @@
import * as path from "path"
import os from "os"
import * as vscode from "vscode"
import { getHostBridgeProvider } from "@/hosts/host-providers"
import os from "os"
import * as path from "path"
import * as vscode from "vscode"
/*
The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
@@ -103,9 +103,9 @@ export function getReadablePath(cwd: string, relPath?: string): string {
}
// Returns the path of the first workspace directory, or the defaultCwdPath if there is no workspace open.
export const getCwd = async (defaultCwdPath = ""): Promise<string> => {
const workspaceFolders = await getHostBridgeProvider().workspaceClient.getWorkspacePaths({})
return workspaceFolders.paths.shift() || defaultCwdPath
export async function getCwd(defaultCwd = ""): Promise<string> {
const workspacePaths = await getHostBridgeProvider().workspaceClient.getWorkspacePaths({})
return workspacePaths.paths.shift() || defaultCwd
}
export function getDesktopDir() {
@@ -113,31 +113,60 @@ export function getDesktopDir() {
}
// Returns the workspace path of the file in the current editor.
// If there is no path, it returns the top level workspace directory.
export const getWorkspacePath = async (defaultCwdPath = "") => {
const currentFileUri = vscode.window.activeTextEditor?.document.uri
const cwdPath = await getCwd(defaultCwdPath)
if (currentFileUri) {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri)
return workspaceFolder?.uri.fsPath || cwdPath
// If there is no open file, it returns the top level workspace directory.
export async function getWorkspacePath(defaultCwd = ""): Promise<string> {
const currentFilePath = vscode.window.activeTextEditor?.document.uri.fsPath
if (!currentFilePath) {
return await getCwd(defaultCwd)
}
return cwdPath
const workspacePaths = (await getHostBridgeProvider().workspaceClient.getWorkspacePaths({})).paths
for (const workspacePath of workspacePaths) {
if (isLocatedInPath(workspacePath, currentFilePath)) {
return workspacePath
}
}
return await getCwd(defaultCwd)
}
export const isLocatedInWorkspace = async (pathToCheck: string = ""): Promise<boolean> => {
const workspacePath = await getWorkspacePath()
export async function isLocatedInWorkspace(pathToCheck: string = ""): Promise<boolean> {
const workspacePaths = (await getHostBridgeProvider().workspaceClient.getWorkspacePaths({})).paths
for (const workspacePath of workspacePaths) {
const resolvedPath = path.resolve(workspacePath, pathToCheck)
if (isLocatedInPath(workspacePath, resolvedPath)) {
return true
}
}
return false
}
// Returns true if `pathToCheck` is located inside `dirPath`.
export function isLocatedInPath(dirPath: string, pathToCheck: string): boolean {
if (!dirPath || !pathToCheck) {
return false
}
// Handle long paths in Windows
if (pathToCheck.startsWith("\\\\?\\") || workspacePath.startsWith("\\\\?\\")) {
return pathToCheck.startsWith(workspacePath)
if (dirPath.startsWith("\\\\?\\") || pathToCheck.startsWith("\\\\?\\")) {
return pathToCheck.startsWith(dirPath)
}
// Normalize paths without resolving symlinks
const normalizedWorkspace = path.normalize(workspacePath)
const normalizedPath = path.normalize(path.resolve(workspacePath, pathToCheck))
const relativePath = path.relative(path.resolve(dirPath), path.resolve(pathToCheck))
if (relativePath.startsWith("..")) {
return false
}
if (path.isAbsolute(relativePath)) {
// This can happen on windows when the two paths are on different drives.
return false
}
return true
}
// Use path.relative to check if the path is within the workspace
const relativePath = path.relative(normalizedWorkspace, normalizedPath)
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath)
export async function asRelativePath(filePath: string): Promise<string> {
const workspacePaths = await getHostBridgeProvider().workspaceClient.getWorkspacePaths({})
for (const workspacePath of workspacePaths.paths) {
if (isLocatedInPath(workspacePath, filePath)) {
return path.relative(workspacePath, filePath)
}
}
return filePath
}

Some files were not shown because too many files have changed in this diff Show More