Compare commits

..
124 changed files with 3723 additions and 10720 deletions
-7
View File
@@ -1,7 +0,0 @@
---
"claude-dev": minor
---
Add Bedrock prompt caching support (optional).
This feature protected under checkbox because it is not yet rolled out to everyone, and if you will try to send cache headers, and its not enabled for you, you will get error.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
refactor
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
refactor
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
refactor
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
refactor
+18
View File
@@ -0,0 +1,18 @@
---
"claude-dev": minor
---
## Checkpoints 2.0: **User-Configurable Checkpoints** Settings
### New Features
- **Global File Exclusions List:** Comprehensive list of files to exclude (build outputs, media files, logs, etc.)
- **Checkpoint Cleanup:** New command to delete all checkpoints and reclaim storage space
### Quality of Life
- **Settings Panel:** Manage all checkpoint settings in one convenient location, providing space for easily adding future checkpoint settings
- **Space Management:** More control over what gets saved in your checkpoints
- **Smart Defaults:** Out-of-the-box checkpoint settings that work for most projects
Settings are preserved when updating, your existing configuration will work as expected.
---
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fixes an issue with Azure API version detection in the OpenAI provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add size calculation to "Delete all Tasks" button
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
refactor
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
refactor
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
SuccessButton to Tailwind
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
refactor
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add saito-sv as CodeOwner
+1 -1
View File
@@ -1 +1 @@
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash
+162
View File
@@ -0,0 +1,162 @@
name: Changeset Release
run-name: Changeset Release ${{ github.actor != 'cline-bot' && '- Create PR' || '- Update Changelog' }}
permissions:
contents: write
pull-requests: write
on:
workflow_dispatch:
pull_request:
types: [closed, opened, labeled]
env:
REPO_PATH: ${{ github.repository }}
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
jobs:
# Job 1: Create version bump PR when changesets are merged to main
changeset-pr-version-bump:
if: >
( github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'cline-bot' ) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Git Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
fetch-depth: 0
ref: ${{ env.GIT_REF }}
- name: Setup Node.js
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4
with:
node-version: 20
cache: "npm"
- name: Install Dependencies
run: npm run install:all
# Check if there are any new changesets to process
- name: Check for changesets
id: check-changesets
run: |
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
echo "Changesets diff with previous version: $NEW_CHANGESETS"
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
# Create version bump PR using changesets/action if there are new changesets
- name: Changeset Pull Request
if: steps.check-changesets.outputs.new_changesets != '0'
id: changesets
uses: changesets/action@e9cc34b540dd3ad1b030c57fd97269e8f6ad905a # v1
with:
commit: "changeset version bump"
title: "Changeset version bump"
version: npm run version-packages # This performs the changeset version bump
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Job 2: Process version bump PR created by cline-bot
changeset-pr-edit-approve:
name: Auto approve and merge Bump version PRs
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.base.ref == 'main' &&
github.actor == 'cline-bot' &&
contains(github.event.pull_request.title, 'Changeset version bump')
steps:
- name: Determine checkout ref
id: checkout-ref
run: |
echo "Event action: ${{ github.event.action }}"
echo "Actor: ${{ github.actor }}"
echo "Head ref: ${{ github.head_ref }}"
echo "PR SHA: ${{ github.event.pull_request.head.sha }}"
if [[ "${{ github.event.action }}" == "opened" && "${{ github.actor }}" == "cline-bot" ]]; then
echo "Using branch ref: ${{ github.head_ref }}"
echo "git_ref=${{ github.head_ref }}" >> $GITHUB_OUTPUT
else
echo "Using SHA ref: ${{ github.event.pull_request.head.sha }}"
echo "git_ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
fi
- name: Checkout Repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ steps.checkout-ref.outputs.git_ref }}
# Get current and previous versions to edit changelog entry
- name: Get version
id: get_version
run: |
VERSION=$(git show HEAD:package.json | jq -r '.version')
echo "version=$VERSION" >> $GITHUB_OUTPUT
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
echo "version=$VERSION"
echo "prev_version=$PREV_VERSION"
# Update CHANGELOG.md with proper format
- name: Update Changelog Format
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
env:
VERSION: ${{ steps.get_version.outputs.version }}
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
run: python .github/scripts/overwrite_changeset_changelog.py
# Commit and push changelog updates
- name: Push Changelog updates
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
run: |
git config user.name "cline-bot"
git config user.email github-actions@github.com
echo "Running git add and commit..."
git add CHANGELOG.md
git commit -m "Updating CHANGELOG.md format"
git status
echo "--------------------------------------------------------------------------------"
echo "Pushing to remote..."
echo "--------------------------------------------------------------------------------"
git push
# Add label to indicate changelog has been formatted
- name: Add changelog-ready label
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['changelog-ready']
});
# Auto-approve PR only after it has been labeled
- name: Auto approve PR
if: contains(github.event.pull_request.labels.*.name, 'changelog-ready')
uses: hmarr/auto-approve-action@de8bf34d0402c38aa2c8346973342b2cb02c4435 # v4
with:
review-message: "I'm approving since it's a bump version PR"
# Auto-merge PR
- name: Automerge on PR
if: false # Needs enablePullRequestAutoMerge in repo settings to work contains(github.event.pull_request.labels.*.name, 'changelog-ready')
run: gh pr merge --auto --merge ${{ github.event.pull_request.number }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -49
View File
@@ -1,56 +1,13 @@
# Changelog
## [3.7.1]
- Fix issue with 'See more' button in task header not showing when starting new tasks
- Fix issue with checkpoints using local git commit hooks
## [3.7.0]
- Cline now displays selectable options when asking questions or presenting a plan, saving you from having to type out responses!
- Add support for a `.clinerules/` directory to load multiple files at once (thanks @ryo-ma!)
- Prevent Cline from reading extremely large files into context that would overload context window
- Improve checkpoints loading performance and display warning for large projects not suited for checkpoints
- Add SambaNova API provider (thanks @saad-noodleseed!)
- Add VPC endpoint option for AWS Bedrock profiles (thanks @minorunara!)
- Add DeepSeek-R1 to AWS Bedrock (thanks @watany-dev!)
## [3.6.5]
- Add 'Delete all Task History' button to History view
- Add toggle to disable model switching between Plan/Act modes in Settings (new users default to disabled)
- Add temperature option to OpenAI Compatible
- Add Kotlin support to tree-sitter parser (thanks @fumiya-kume!)
## [3.6.3]
- Improve QwQ support for Alibaba (thanks @meglinge!) and OpenRouter
- Improve diff edit prompting to prevent immediately reverting to write_to_file when a model uses search patterns that don't match anything in the file
- Fix bug where new checkpoints system would revert file changes when switching between tasks
- Fix issue with incorrect token count for some OpenAI compatible providers
## [3.6.0]
- Add Cline API as a provider option, allowing new users to sign up and get started with Cline for free
- Optimize checkpoints with branch-per-task strategy, reducing storage required and first task load times
- Fix problem with Plan/Act toggle keyboard shortcut not working in Windows (thanks @yt3trees!)
- Add new Gemini models to GCP Vertex (thanks @shohei-ihaya!) and Claude models AskSage (thanks @swhite24!)
- Improve OpenRouter/Cline error reporting
## [3.5.1]
- Add timeout option to MCP servers
- Add Gemini Flash models to Vertex provider (thanks @jpaodev!)
- Add prompt caching support for AWS Bedrock provider (thanks @buger!)
- Add AskSage provider (thanks @swhite24!)
## [3.5.0]
- Add 'Enable extended thinking' option for Claude 3.7 Sonnet, with ability to set different budgets for Plan and Act modes
- Add support for rich MCP responses with automatic image previews, website thumbnails, and WolframAlpha visualizations
- Add language preference option in Advanced Settings
- Add xAI Provider Integration with support for all Grok models (thanks @andrewmonostate!)
- Fix issue with Linux XDG pointing to incorrect path for Document folder (thanks @jonatkinson!)
- Add Sonnet 3.7 thinking model option to Anthropic provider (thanks @celestial-vault!)
- Update checkpoints with improved storage optimization & faster performance (thanks @tiki_brc!)
- New Rich MCP Responses with automatic image previews, website thumbnails, and WolframAlpha visualizations right in your conversation (thanks @.lung!)
- Added xAI Provider Integration with support for all Grok models, including the massive 131K token context window for large codebases (thanks @ocasta181!)
- Language preferences feature to set your preferred language in settings (thanks @brownrw8!)
- Added Linux XDG Support for user's document folder settings (thanks @jonatkinson!)
## [3.4.10]
+1 -1
View File
@@ -1,5 +1,5 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a>
</sub></div>
# Cline \#1 on OpenRouter
+116 -1
View File
@@ -1 +1,116 @@
See [https://cline.bot/privacy](https://cline.bot/privacy) for our privacy policy.
# Cline Privacy Policy
Cline Bot Inc. ("Cline," "we," "our," and/or "us") values the privacy of individuals who use our VS Code extension and related services (collectively, our "Services"). This privacy policy explains how we collect, use, and disclose information from users of our Services.
## Key Points
- Cline operates entirely client-side as a VS Code extension
- No code or data is collected, stored, or transmitted to Cline's servers
- Your data is only sent to your chosen AI provider (e.g., Anthropic, OpenAI) when you explicitly request assistance
- All processing happens locally on your machine
- API keys are stored securely in VS Code's built-in settings storage
## Information We Process
### A. Information You Provide
- **API Keys**: When you choose to use certain AI model providers (OpenRouter, Anthropic, OpenAI, etc.), you provide API keys. These are stored securely and locally in your VS Code settings.
- **Communications**: If you contact us directly (e.g., via Discord or email), we may receive information like your name, email address, and message contents.
### B. Information Processing
Cline functions solely as a client-side VS Code extension that facilitates communication between your editor and your chosen AI model provider:
1. **File Contents**:
- Only sent to your chosen AI provider when you explicitly request assistance
- Never stored or transmitted to Cline's servers
- Only the specific files/content you select are included
2. **Terminal Commands**:
- Processed entirely locally on your machine
- Require explicit user confirmation before execution
- No command history is transmitted to Cline
3. **Browser Integration**:
- Screenshots and console logs are processed locally
- Temporary data is cleared after task completion
## Data Security
1. **Local-Only Processing**:
- All operations happen on your local machine
- No central servers or data collection by default
- Anonymous telemetry and usage statistics are only collected if you explicitly opt in
- No account creation required
2. **API Key Security**:
- Stored using VS Code's secure settings storage system
- Never transmitted to Cline's servers
- You can remove/modify keys at any time
3. **User Control**:
- Explicit approval required for file changes
- Terminal commands require confirmation
- Browser actions need explicit permission
- You control which AI provider to use
## Communication with AI Providers
When you request assistance:
1. Selected content is sent directly to your chosen AI provider
2. No data passes through Cline's servers
3. Provider's own privacy policy applies to this communication:
- [Anthropic Privacy Policy](https://www.anthropic.com/privacy)
- [OpenAI Privacy Policy](https://openai.com/privacy)
- [OpenRouter Privacy Policy](https://openrouter.ai/privacy)
## Error Handling & Debugging
- Error logs are processed locally
- No automatic error reporting to Cline
- Optional anonymous telemetry and error reporting via PostHog if you opt in
- You control what information to include when manually reporting issues
## Children's Privacy
We do not knowingly collect, maintain, or use personal information from children under 18 years of age, and no part of our Service(s) is directed to children. If you learn that a child has provided us with personal information in violation of this Privacy Policy, then you may alert us at support@cline.bot.
## Changes to Privacy Policy
We will post any changes to this policy on our GitHub repository. Significant changes will be announced in our Discord community.
## Security Concerns & Auditing
- Cline is open source and available for security audit
- Our client-side architecture ensures no central point of data collection
- You can inspect exactly what data is being sent to AI providers
- Enterprise users can implement additional access controls through VS Code
## Telemetry & Usage Statistics
If you choose to opt in to anonymous telemetry:
- Basic usage statistics and error reports are collected via PostHog
- A stable, anonymous identifier (VS Code's `machineId`) is used to understand unique usage patterns
- This identifier is not linked to any personal information
- It helps us understand how features are used across sessions
- It cannot be used to identify you personally
- All data is anonymized and cannot be linked to individual users
- No code content or sensitive information is ever included
- You can opt out at any time through:
- VS Code Settings > Cline > Enable Telemetry
- VS Code Settings > Telemetry > Telemetry Level (setting this to anything other than "all" will disable Cline's telemetry)
- Collected data helps us improve the extension's functionality and stability
## Contact Us
For privacy-related questions or concerns:
- Open an issue on our [GitHub repository](https://github.com/cline/cline)
- Join our [Discord community](https://discord.gg/cline)
- Email: support@cline.bot
-21
View File
@@ -128,27 +128,6 @@ Cline's system prompt, on the other hand, is not user-editable ([here's where yo
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
- Test and Iterate: Experiment to find what works best for your workflow.
### Support for Loading Files from the `.clinerules/` Directory
All files under the `.clinerules/` directory are recursively loaded, and their contents are merged into clineRulesFileInstructions.
#### Example 1:
```
.clinerules/
├── .local-clinerules
└── .project-clinerules
```
#### Example 2:
```
.clinerules/
├── .clinerules-nextjs
├── .clinerules-serverside
└── tests/
├── .pytest-clinerules
└── .jest-clinerules
```
## Prompting Cline 💬
**Prompting is how you communicate your needs for a given task in the back-and-forth chat with Cline.** Cline understands natural language, so write conversationally.
-1
View File
@@ -52,7 +52,6 @@ const copyWasmFiles = {
"java",
"php",
"swift",
"kotlin",
]
languages.forEach((lang) => {
+1 -1
View File
@@ -158,5 +158,5 @@ Um zum Projekt beizutragen, beginnen Sie mit unserem [Beitragsleitfaden](CONTRIB
## Lizenz
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+1 -1
View File
@@ -158,4 +158,4 @@ Para contribuir al proyecto, comience con nuestra [guía de contribución](CONTR
## Licencia
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+1 -1
View File
@@ -158,4 +158,4 @@ Clineがタスクを進める中で、拡張機能は各ステップでワーク
## ライセンス
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
-47
View File
@@ -1,47 +0,0 @@
# 기여자 행동 강령
## 서약
우리는 개방적이고 환영하는 환경을 조성하기 위해 노력하며, 기여자 및 유지 관리자로서 모든 사람이 차별과 괴롭힘 없이 프로젝트와 커뮤니티에 참여할 수 있도록 최선을 다할 것을 서약합니다. 이는 연령, 체형, 장애, 민족성, 성적 특성, 성 정체성 및 표현, 경험 수준, 교육 수준, 사회·경제적 지위, 국적, 외모, 인종, 종교, 성 정체성과 성적 지향에 관계없이 모든 사람에게 적용됩니다.
## 행동 기준
긍정적인 환경을 조성하기 위한 바람직한 행동의 예시:
- 환영하고 포용적인 언어 사용하기
- 서로 다른 관점과 경험을 존중하기
- 건설적인 비판을 우아하게 수용하기
- 커뮤니티에 최선이 되는 것에 집중하기
- 다른 커뮤니티 구성원들에 대한 공감 보여주기
참여자가 해서는 안 되는 행동의 예시:
- 성적인 언어와 이미지 사용, 원치 않는 성적 관심이나 접근
- 트롤링, 모욕적/경멸적인 댓글, 개인적 또는 정치적 공격
- 공개적 또는 사적인 괴롭힘
- 상대방의 동의 없이 개인정보(실제 주소나 전자 주소 등) 공개하기
- 전문적 환경에서 부적절하다고 여겨질 수 있는 기타 행위
## 책임
프로젝트 유지 관리자는 허용 가능한 행동 기준을 명확히 설명할 책임이 있으며, 부적절한 행동이 발생할 경우 적절하고 공정한 시정 조치를 취해야 합니다.
프로젝트 유지 관리자는 본 행동 강령에 부합하지 않는 댓글, 커밋, 코드, 위키 수정, 이슈 및 기타 기여를 삭제, 수정 또는 거부할 권리와 책임이 있으며, 부적절하다고 판단되는 행동(위협적이거나, 공격적이거나, 해로운 행위 등)을 한 기여자를 일시적 또는 영구적으로 차단할 권리를 가집니다.
## 범위
이 행동 강령은 프로젝트 공간과 개인이 프로젝트나 커뮤니티를 대표하는 공개 공간에서 모두 적용됩니다. 프로젝트 또는 커뮤니티를 대표하는 예로는 공식 프로젝트 이메일 주소 사용, 공식 소셜 미디어 계정을 통한 게시, 온라인 또는 오프라인 행사에서 지정된 대표자로 활동하는 경우 등이 포함됩니다. 프로젝트의 대표성은 프로젝트 유지 관리자가 추가로 정의하고 명확히 할 수 있습니다.
## 집행
학대, 괴롭힘 또는 기타 용납할 수 없는 행동은 프로젝트 팀에 hi@cline.bot을 통해 신고 할 수 있습니다. 모든 신고는 검토 및 조사되며, 상황에 따라 필요하고 적절한 조치가 취해질 것입니다. 프로젝트 팀은 사건 신고자의 신원을 보호할 의무가 있습니다. 특정 시행 정책에 대한 추가 세부 사항은 별도로 게시될 수 있습니다.
행동 강령을 성실히 준수하거나 집행하지 않는 프로젝트 유지관리자는 프로젝트 리더십의 구성원에 의해 일시적 또는 영구적인 제재를 받을 수 있습니다.
## 출처
이 행동 강령은 [Contributor Covenant][homepage] 버전 1.4에서 수정되었으며, https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 에서 확인할 수 있습니다.
[homepage]: https://www.contributor-covenant.org
이 행동 강령에 대한 일반적인 질문에 대한 답변은 https://www.contributor-covenant.org/faq 를 참조하시기 바랍니다.
-92
View File
@@ -1,92 +0,0 @@
# Cline에 기여하기
Cline에 기여하는 것에 관심을 가져주셔서 감사합니다! 버그 수정, 기능 추가, 문서 개선 등 모든 기여는 Cline을 더욱 스마트하게 만드는 데 기여합니다. 활기차고 환영하는 커뮤니티를 유지하기 위해 모든 구성원은 [행동 강령](CODE_OF_CONDUCT.md)을 준수해야 합니다.
## 버그와 문제 보고
버그 보고는 Cline을 모두에게 더 나은 것으로 만드는 데 도움이 됩니다! 새로운 이슈를 생성하기 전에, 중복을 피하기 위해 [기존 이슈를 검색](https://github.com/cline/cline/issues)해 주세요. 버그를 보고할 준비가 되었다면, [이슈 페이지](https://github.com/cline/cline/issues/new/choose)로 이동하여 관련 정보를 작성하기 위한 템플릿을 사용해 주세요.
<blockquote class='warning-note'>
🔐 <b>중요:</b> 보안 취약점을 발견한 경우, <a href="https://github.com/cline/cline/security/advisories/new">GitHub 보안 도구를 사용하여 비공개로 보고</a>해 주세요.
</blockquote>
## 작업 내용 결정하기
첫 기여를 찾고 계신가요? ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)나 ["help wanted"](https://github.com/cline/cline/labels/help%20wanted) 라벨이 붙은 이슈를 확인해 보세요. 이러한 이슈들은 새로운 기여자를 위해 특별히 선정된 작업으로, 도움이 필요한 영역이 표시되어 있습니다!
또한, [문서](https://github.com/cline/cline/tree/main/docs)에 대한 기여도 환영합니다! 오타 수정, 기존 가이드 개선, 새로운 교육 콘텐츠 작성 등, 커뮤니티 주도의 리소스 저장소를 구축하는 데 여러분의 도움이 필요합니다. `/docs`를 살펴보고 개선이 필요한 부분을 찾아보세요.
큰 기능에 대해 작업할 계획이 있다면, 먼저 [기능 요청](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop)을 생성하여 이것이 Cline의 비전과 부합하는지 논의하는 것이 좋습니다.
## 개발 환경 설정
1. **VS Code 확장 프로그램**
- 프로젝트를 열면 VS Code가 권장 확장 프로그램 설치를 안내합니다
- 개발을 위해 이 확장 프로그램들이 필요하므로, 설치 안내를 수락해 주세요.
- 프롬프트를 닫은 경우 확장 프로그램 패널에서 수동으로 설치할 수 있습니다
2. **로컬 개발**
- `npm run install:all`을 실행하여 의존성을 설치합니다
- `npm run test`를 실행하여 로컬에서 테스트를 실행합니다
- PR을 제출하기 전에 `npm run format:fix`를 실행하여 코드를 포맷팅합니다
## 코드 작성과 제출
누구나 Cline에 코드를 기여할 수 있지만, 기여가 원활하게 통합되도록 다음 가이드라인을 따라주세요:
1. **Pull Request 집중하기**
- PR은 단일 기능 또는 버그 수정으로 제한해 주세요
- 큰 변경사항은 작은 관련 PR로 분할해 주세요
- 논리적으로 독립적인 커밋 단위로 나누어 리뷰가 용이하도록 구성하세요.
2. **코드 품질**
- `npm run lint`를 실행하여 코드 스타일을 체크합니다
- `npm run format`을 실행하여 코드를 자동으로 포맷팅합니다
- 모든 PR은 린팅과 포맷팅을 포함한 CI 체크를 통과해야 합니다
- 제출 전에 ESLint 경고나 에러를 모두 해결해 주세요
- TypeScript 모범 사례를 따르고, 타입 안전성을 유지해 주세요
3. **테스트**
- 새로운 기능에는 테스트를 추가해 주세요
- `npm test`를 실행하여 모든 테스트가 통과하는지 확인해 주세요
- 변경사항이 기존 테스트에 영향을 미치는 경우 해당 테스트를 업데이트해 주세요
- 적절한 경우 단위 테스트와 통합 테스트를 모두 포함해 주세요
4. **Changesets를 활용한 버전 관리**
- 사용자에게 영향을 미치는 변경 사항이 있는 경우, `npm run changeset`을 실행하여 changeset을 생성해 주세요
- 적절한 버전 증가 옵션을 선택하세요:
- `major` 호환되지 않는 변경 (1.0.0 → 2.0.0)
- `minor` 새로운 기능 추가 (1.0.0 → 1.1.0)
- `patch` 버그 수정 (1.0.0 → 1.0.1)
- 영향을 설명하는 명확한 변경사항 메시지를 작성해 주세요
- 문서 변경만 있는 경우 changeset이 필요하지 않습니다
5. **커밋 가이드라인**
- 명확하고 설명적인 커밋 메시지를 작성해 주세요
- 컨벤셔널 커밋 형식(예: "feat:", "fix:", "docs:")을 사용해 주세요
- 커밋에서 관련 이슈를 #issue-number를 사용하여 참조해 주세요
6. **제출 전 확인사항**
- 최신 main에 브랜치를 리베이스해 주세요
- 브랜치가 정상적으로 빌드되는지 확인해 주세요
- 모든 테스트가 통과하는지 다시 확인해 주세요
- 디버그 코드나 콘솔 로그가 없는지 변경사항을 확인해 주세요
7. **Pull Request 설명**
- 변경 내용을 명확하게 설명해 주세요
- 변경사항을 테스트하는 방법을 포함해 주세요
- 호환되지 않는 변경 사항이 있다면 목록으로 작성해주세요
- UI 변경이 있는 경우, 스크린샷을 추가해 주세요
## 기여 동의서
Pull Request를 제출함으로써, 귀하의 기여가 프로젝트와 동일한 라이선스([Apache 2.0](/LICENSE)) 에 따라 제공됨에 동의하는 것입니다.
기억하세요: Cline에 기여하는 것은 코드를 작성하는 것뿐만 아니라, AI 지원 개발의 미래를 형성하는 커뮤니티의 일원이 되는 것입니다. 함께 멋진 것을 만들어봅시다! 🚀
-172
View File
@@ -1,172 +0,0 @@
# Cline - 최고의 OpenRouter
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
</p>
<div align="center">
<table>
<tbody>
<td align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>VS Marketplace에서 다운로드</strong></a>
</td>
<td align="center">
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
</td>
<td align="center">
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
</td>
<td align="center">
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>기능 요청</strong></a>
</td>
<td align="center">
<a href="https://cline.bot/join-us" target="_blank"><strong>채용 정보</strong></a>
</td>
</tbody>
</table>
</div>
Cline을 만나보세요, **CLI** 및 **에디터**를 활용할 수 있는 AI 어시스턴트입니다.
[Claude 3.7 Sonnet의 에이전트형 코딩 기능](https://www.anthropic.com/claude/sonnet) 덕분에, Cline은 복잡한 소프트웨어 개발 작업을 단계별로 처리할 수 있습니다. 파일 생성과 편집, 대규모 프로젝트 탐색, 브라우저 사용, 터미널 명령 실행(권한 허가 필요) 등의 도구를 사용하여 단순 코드 완성이나 기술 지원을 넘어서는 도움을 제공합니다. Cline은 Model Context Protocol(MCP)를 사용하여 새로운 도구를 만들고 자신의 기능을 확장할 수도 있습니다. 자율적인 AI 스크립트는 일반적으로 샌드박스 환경에서 실행되지만, 이 확장 프로그램은 모든 파일 변경 및 터미널 명령을 승인할 수 있는 사람이 개입가능한 GUI를 제공하여, 에이전트형 AI의 잠재력을 보다 안전하고 쉽게 탐색할 수 있도록 합니다.
1. 작업을 입력하고, 목업을 기능하는 앱으로 변환하거나 스크린샷으로 버그를 수정합니다.
2. Cline은 파일 구조와 소스코드 AST의 분석, 정규식 검색 실행, 관련 파일 읽기부터 시작하여 기존 프로젝트를 파악합니다. 또한, 어떤 정보를 컨텍스트에 추가할지를 신중하게 관리하여, 대규모 복잡한 프로젝트에서도 컨텍스트 윈도우를 과부하시키지 않으면서도 효과적인 지원을 제공합니다.
3. Cline이 필요한 정보를 얻은 후 다음과 같은 작업을 할 수 있습니다:
- 파일 생성과 편집 + 린터/컴파일러 오류 모니터링을 수행하여 누락된 임포트나 구문 오류 등의 문제를 자동으로 수정합니다.
- 터미널에서 명령을 직접 실행하고 작업 중에 출력을 모니터링합니다. 이를 통해 파일 편집 후 개발 서버의 문제에 대응할 수 있습니다.
- 웹 개발 작업에서는 헤드리스 브라우저로 사이트를 실행하고, 클릭, 입력, 스크롤, 스크린샷과 콘솔 로그 캡처를 수행하여 런타임 오류나 시각적 버그를 수정합니다.
4. 작업이 완료되면 Cline은 `open -a "Google Chrome" index.html`과 같은 터미널 명령을 제공하여 버튼 클릭 한 번으로 결과를 확인할 수 있도록 합니다.
> [!TIP]
> `CMD/CTRL + Shift + P` 단축키를 사용하여 명령 팔레트를 열고 "Cline: Open In New Tab"을 입력하여 에디터의 탭으로 확장 프로그램을 엽니다. 이를 통해 파일 탐색기와 병행하여 Cline을 사용하고 워크스페이스의 변경을 더 명확하게 확인할 수 있습니다.
---
<img align="right" width="340" src="https://github.com/user-attachments/assets/3cf21e04-7ce9-4d22-a7b9-ba2c595e88a4">
### 어떤 API나 모델이든 사용 가능
Cline은 OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex 등의 API 제공자를 지원합니다. 또한 OpenAI 호환 API를 설정하거나 LM Studio/Ollama를 통해 로컬 모델을 사용할 수도 있습니다. OpenRouter를 사용하는 경우, 확장 프로그램에서 최신 모델 목록을 가져와 바로 최신 모델을 사용할 수 있게 합니다.
또한, Cline은 전체 작업 루프와 개별 요청별로 토큰 사용량과 API 비용을 추적하여, 진행 중인 작업의 비용을 실시간으로 확인할 수 있도록 도와줍니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
### 터미널에서 명령 실행
VSCode v1.93의 새로운 [셸 통합 업데이트](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) 덕분에, Cline은 터미널에서 명령을 직접 실행하고 출력을 받을 수 있습니다. 이를 통해 패키지 설치나 빌드 스크립트 실행부터 애플리케이션 배포, 데이터베이스 관리, 테스트 실행까지 광범위한 작업을 수행할 수 있습니다. Cline은 개발 환경과 도구 체인에 맞추어 정확하게 작업을 실행합니다.
개발 서버와 같은 오래 실행되는 프로세스의 경우, "실행 중 계속"(Proceed While Running) 버튼을 사용하여 명령이 백그라운드에서 실행되는 동안 Cline이 작업을 계속할 수 있게 합니다. 작업이 진행되는 동안 Cline은 새로운 터미널 출력을 실시간으로 확인하여, 파일 편집 시 발생하는 컴파일 오류와 같은 문제에 즉시 대응할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
### 파일 생성과 편집
Cline은 에디터 내에서 파일을 생성 및 편집하고 변경의 Diff 뷰로 표시합니다. Diff 뷰 에디터에서 Cline의 변경을 직접 편집하거나 되돌릴 수 있으며, 채팅에서 피드백을 제공하여 만족할 때까지 개선 요청할 수 있습니다. Cline은 린터/컴파일러 오류(누락된 임포트, 구문 오류 등)도 모니터링하고 발생한 문제를 자동으로 수정합니다.
Cline에 의한 모든 변경은 파일의 타임라인에 기록되어 필요할 때 변경을 추적하고 되돌릴 수 있는 간단한 방법을 제공합니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
### 브라우저 사용
Claude 3.5 Sonnet의 새로운 [컴퓨터 사용](https://www.anthropic.com/news/3-5-models-and-computer-use) 기능으로 인해, Cline은 브라우저를 실행하고 요소를 클릭하고 텍스트를 입력하고 스크롤하며 각 단계에서 스크린샷과 콘솔 로그를 캡처할 수 있습니다. 이를 통해 인터랙티브한 디버깅, 엔드투엔드 테스트, 심지어 일반적인 웹 탐색까지 가능해집니다. 이로 인해 오류 로그를 수동으로 복사 & 붙여넣기 할 필요 없이 시각적 버그나 런타임 문제를 자율적으로 수정할 수 있습니다.
Cline에게 "앱을 테스트해줘"라고 요청하면, `npm run dev`와 같은 명령을 실행하고 로컬에서 실행 중인 개발 서버를 브라우저에서 실행하여 일련의 테스트를 수행하고 모든 것이 정상적으로 작동하는지 확인합니다. [데모는 여기를 참조하세요.](https://x.com/sdrzn/status/1850880547825823989)
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
### "도구를 추가 해주세요."
Cline은 [Model Context Protocol](https://github.com/modelcontextprotocol)을 활용하여 커스텀 도구를 생성하고 기능을 확장할 수 있습니다. 기존의 [커뮤니티 서버](https://github.com/modelcontextprotocol/servers)를 사용할 수도 있지만, Cline은 사용자의 워크플로우에 최적화된 도구를 직접 제작하고 설치할 수도 있습니다. "~ 도구를 추가해주세요."라고 요청만 하면, Cline은 새로운 MCP 서버 생성부터 확장 프로그램 내 설치까지 모두 자동으로 처리합니다. 이러한 커스텀 도구는 Cline의 툴키트의 일부가 되어 향후 작업에서 사용할 수 있게 됩니다.
- "Jira 티켓을 가져오는 도구를 추가해주세요": 티켓 AC를 가져와 Cline에게 작업을 요청
- "AWS EC2를 관리하는 도구를 추가해주세요": 서버 메트릭을 확인하고 인스턴스를 확장 또는 축소
- "최신 PagerDuty 인시던트를 가져오는 도구를 추가해주세요": 최신 장애 정보를 가져와 Cline에게 버그 수정 요청
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
### 컨텍스트 추가
**`@url`:** URL을 붙여넣으면 확장이 해당 페이지를 가져와 Markdown으로 변환합니다. 최신 문서를 Cline에게 제공할 때 유용합니다.
**`@problems`:** Cline이 수정할 워크스페이스 오류와 경고(Problems' panel)를 추가합니다.
**`@file`:** 파일의 내용을 추가하여, 파일을 읽는 데 API 요청을 허비하지 않고도 Cline이 접근할 수 있도록 합니다. (+ 파일 검색 가능)
**`@folder`:** 폴더 내 모든 파일을 한 번에 추가하여 워크플로우를 더욱 빠르게 진행할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
### 체크포인트: 비교 및 복원
Cline이 작업을 진행하는 동안 확장 프로그램은 각 단계에서 워크스페이스의 스냅샷을 저장합니다. “Compare” 버튼을 사용하여 스냅샷과 현재 워크스페이스의 차이를 확인하고, “Restore” 버튼을 사용하여 해당 시점으로 롤백할 수 있습니다.
예를 들어, 로컬 웹 서버에서 작업 중일 때 “Restore Workspace Only”을 사용하여 서로 다른 버전의 앱을 신속하게 테스트하고, “Restore Task and Workspace”을 사용하여 계속 진행할 버전을 찾을 수 있습니다. 이를 통해 진행 상황을 잃지 않고 안전하게 다양한 접근 방식을 실험할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
## 기여
프로젝트에 기여하려면, [기여 가이드](CONTRIBUTING.md)에서 기본 사항을 익히세요. 또한, [Discord](https://discord.gg/cline)에 참여하여 `#contributors` 채널에서 다른 기여자들과 이야기할 수 있습니다. 풀타임 직업을 찾고 있다면, [채용 페이지](https://cline.bot/join-us)에서 열려있는 포지션을 확인하세요.
<details>
<summary>로컬 개발 방법</summary>
1. 리포지토리를 클론합니다 _(Requires [git-lfs](https://git-lfs.com/))_
```bash
git clone https://github.com/cline/cline.git
```
2. 프로젝트를 VSCode에서 엽니다:
```bash
code cline
```
3. 확장 프로그램과 webview-gui의 필요한 의존성을 설치합니다:
```bash
npm run install:all
```
4. `F5`를 눌러(또는 `Run`->`Start Debugging`), 확장 프로그램이 로드된 새로운 VSCode 창을 엽니다. (프로젝트 빌드에 문제가 있는 경우, [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)을 설치해야 할 수도 있습니다.)
</details>
<details>
<summary>Pull Request 생성 방법</summary>
1. PR을 만들기 전, 변경 사항을 기록하는 changeset 항목을 생성:
```bash
npm run changeset
```
이후 프롬프트에서 다음 정보를 입력하세요:
- 변경 유형 (major, minor, patch)
- `major` → 호환되지 않는 변경 (1.0.0 → 2.0.0)
- `minor` → 새로운 기능 추가 (1.0.0 → 1.1.0)
- `patch` → 버그 수정 (1.0.0 → 1.0.1)
- 변경 사항 설명 입력
2. 변경 사항과 생성된 `.changeset` 파일을 커밋 후 브랜치를 푸시하고 GitHub에서 PR을 생성하세요.
3. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
- 테스트 및 코드 검증 실행
- Changesetbot이 버전 변경 영향을 보여주는 코멘트를 생성
- 브랜치가 메인에 머지되면, Changesetbot이 버전 패키지 PR을 생성
- 버전 패키지 PR이 머지되면, 새로운 릴리즈가 게시됨
</details>
## 라이센스
[Apache 2.0 © 2025 Cline Bot Inc.](/LICENSE)
+1 -1
View File
@@ -158,4 +158,4 @@ Para contribuir com o projeto, comece com nosso [Guia de Contribuição](CONTRIB
## Licença
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+1 -1
View File
@@ -158,5 +158,5 @@ Cline 所做的所有更改都会记录在你的文件时间轴中,提供了
## 许可证
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+1 -1
View File
@@ -158,4 +158,4 @@ Cline 所做的所有更改都會記錄在你的文件時間軸中,提供了
## 許可證
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+215 -2867
View File
File diff suppressed because it is too large Load Diff
+9 -15
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.7.1",
"version": "3.5.0",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",
@@ -157,11 +157,6 @@
"default": "full",
"description": "Controls MCP inclusion in prompts, reduces token usage if you only need access to certain functionality."
},
"cline.enableCheckpoints": {
"type": "boolean",
"default": true,
"description": "Enables extension to save checkpoints of workspace throughout the task. Uses git under the hood which may not work well with large workspaces."
},
"cline.disableBrowserTool": {
"type": "boolean",
"default": false,
@@ -211,6 +206,11 @@
"type": "boolean",
"default": true,
"description": "Controls whether the MCP Marketplace is enabled."
},
"cline.modelSettings.anthropic.thinkingBudgetTokens": {
"type": "number",
"default": 0,
"description": "Controls the token budget for Claude's thinking capability. Set to 0 to disable thinking. When enabled, must be ≥ 1024 and less than the model's max token output."
}
}
}
@@ -226,7 +226,7 @@
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
"lint": "eslint src --ext ts",
"format": "prettier . --check",
"format:fix": "prettier . --write",
"test": "vscode-test",
@@ -247,7 +247,6 @@
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/vscode": "^1.84.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
@@ -260,24 +259,21 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"should": "^13.2.3",
"sinon": "^19.0.2",
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.758.0",
"@google-cloud/vertexai": "^1.9.3",
"@google/generative-ai": "^0.18.0",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.7.0",
"@modelcontextprotocol/sdk": "^1.0.1",
"@types/clone-deep": "^4.0.4",
"@types/get-folder-size": "^3.0.4",
"@types/pdf-parse": "^1.1.4",
"@types/turndown": "^5.0.5",
"@vscode/codicons": "^0.0.36",
"axios": "^1.8.2",
"axios": "^1.7.4",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"clone-deep": "^4.0.1",
@@ -293,11 +289,9 @@
"isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"ollama": "^0.5.13",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-timeout": "^6.1.4",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"posthog-node": "^4.8.1",
+1 -11
View File
@@ -9,23 +9,19 @@ import { OllamaHandler } from "./providers/ollama"
import { LmStudioHandler } from "./providers/lmstudio"
import { GeminiHandler } from "./providers/gemini"
import { OpenAiNativeHandler } from "./providers/openai-native"
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
import { ApiStream } from "./transform/stream"
import { DeepSeekHandler } from "./providers/deepseek"
import { RequestyHandler } from "./providers/requesty"
import { TogetherHandler } from "./providers/together"
import { QwenHandler } from "./providers/qwen"
import { MistralHandler } from "./providers/mistral"
import { VsCodeLmHandler } from "./providers/vscode-lm"
import { ClineHandler } from "./providers/cline"
import { LiteLlmHandler } from "./providers/litellm"
import { AskSageHandler } from "./providers/asksage"
import { XAIHandler } from "./providers/xai"
import { SambanovaHandler } from "./providers/sambanova"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
getModel(): { id: string; info: ModelInfo }
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
}
export interface SingleCompletionHandler {
@@ -65,16 +61,10 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new MistralHandler(options)
case "vscode-lm":
return new VsCodeLmHandler(options)
case "cline":
return new ClineHandler(options)
case "litellm":
return new LiteLlmHandler(options)
case "asksage":
return new AskSageHandler(options)
case "xai":
return new XAIHandler(options)
case "sambanova":
return new SambanovaHandler(options)
default:
return new AnthropicHandler(options)
}
+19 -8
View File
@@ -1,7 +1,14 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import { withRetry } from "../retry"
import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, ApiHandlerOptions, ModelInfo } from "../../shared/api"
import {
ANTHROPIC_THINKING_BUDGET_TOKENS_MIN,
anthropicDefaultModelId,
AnthropicModelId,
anthropicModels,
ApiHandlerOptions,
ModelInfo,
} from "../../shared/api"
import { ApiHandler } from "../index"
import { ApiStream } from "../transform/stream"
@@ -22,13 +29,10 @@ export class AnthropicHandler implements ApiHandler {
const model = this.getModel()
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
const modelId = model.id
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
switch (modelId) {
// 'latest' alias does not support cache_control
case "claude-3-7-sonnet-20250219":
case "claude-3-7-sonnet-20250219:thinking":
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
case "claude-3-opus-20240229":
@@ -44,12 +48,18 @@ export class AnthropicHandler implements ApiHandler {
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.client.messages.create(
{
model: modelId,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
model: modelId === "claude-3-7-sonnet-20250219:thinking" ? "claude-3-7-sonnet-20250219" : modelId,
thinking:
modelId === "claude-3-7-sonnet-20250219:thinking"
? {
type: "enabled",
budget_tokens: this.options.thinkingBudgetTokens || ANTHROPIC_THINKING_BUDGET_TOKENS_MIN,
}
: undefined,
max_tokens: model.info.maxTokens || 8192,
// "Thinking isnt compatible with temperature, top_p, or top_k modifications as well as forced tool use."
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
temperature: reasoningOn ? undefined : 0,
temperature: modelId === "claude-3-7-sonnet-20250219:thinking" ? 1 : 0,
system: [
{
text: systemPrompt,
@@ -97,6 +107,7 @@ export class AnthropicHandler implements ApiHandler {
// https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393
switch (modelId) {
case "claude-3-7-sonnet-20250219":
case "claude-3-7-sonnet-20250219:thinking":
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
case "claude-3-opus-20240229":
-115
View File
@@ -1,115 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler } from ".."
import {
ApiHandlerOptions,
ModelInfo,
AskSageModelId,
askSageModels,
askSageDefaultModelId,
askSageDefaultURL,
} from "../../shared/api"
import { ApiStream } from "../transform/stream"
type AskSageRequest = {
system_prompt: string
message: {
user: "gpt" | "me"
message: string
}[]
model: string
dataset: "none"
}
type AskSageResponse = {
uuid: string
status: number
// Response status
response: string
// Generated response message
message: string
}
export class AskSageHandler implements ApiHandler {
private options: ApiHandlerOptions
private apiUrl: string
private apiKey: string
constructor(options: ApiHandlerOptions) {
console.log("init api url", options.asksageApiUrl, askSageDefaultURL)
this.options = options
this.apiKey = options.asksageApiKey || ""
this.apiUrl = options.asksageApiUrl || askSageDefaultURL
if (!this.apiKey) {
throw new Error("AskSage API key is required")
}
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
try {
const model = this.getModel()
// Transform messages into AskSageRequest format
const formattedMessages = messages.map((msg) => {
const content = Array.isArray(msg.content)
? msg.content.map((block) => ("text" in block ? block.text : "")).join("")
: msg.content
return {
user: msg.role === "assistant" ? ("gpt" as const) : ("me" as const),
message: content,
}
})
const request: AskSageRequest = {
system_prompt: systemPrompt,
message: formattedMessages,
model: model.id,
dataset: "none",
}
// Make request to AskSage API
const response = await fetch(`${this.apiUrl}/query`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-tokens": this.apiKey,
},
body: JSON.stringify(request),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`AskSage API error: ${error}`)
}
const result = (await response.json()) as AskSageResponse
if (!result.message) {
throw new Error("No content in AskSage response")
}
// Return entire response as a single chunk since streaming is not supported
yield {
type: "text",
text: result.message,
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`AskSage request failed: ${error.message}`)
}
}
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in askSageModels) {
const id = modelId as AskSageModelId
return { id, info: askSageModels[id] }
}
return {
id: askSageDefaultModelId,
info: askSageModels[askSageDefaultModelId],
}
}
}
+22 -332
View File
@@ -1,13 +1,9 @@
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { convertToR1Format } from "../transform/r1-format"
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand } from "@aws-sdk/client-bedrock-runtime"
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
export class AwsBedrockHandler implements ApiHandler {
@@ -17,76 +13,22 @@ export class AwsBedrockHandler implements ApiHandler {
this.options = options
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// cross region inference requires prefixing the model id with the region
let modelId = await this.getModelId()
const model = this.getModel()
// Check if this is a Deepseek model
if (modelId.includes("deepseek")) {
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
return
}
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
// Get model info and message indices for caching
const userMsgIndices = messages.reduce((acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[])
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
// Create anthropic client, using sessions created or renewed after this handler's
// create anthropic client, using sessions created or renewed after this handler's
// initialization, and allowing for session renewal if necessary as well
const client = await this.getAnthropicClient()
let client = await this.getClient()
const stream = await client.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
temperature: reasoningOn ? undefined : 0,
system: [
{
text: systemPrompt,
type: "text",
...(this.options.awsBedrockUsePromptCache === true && {
cache_control: { type: "ephemeral" },
}),
},
],
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
...(this.options.awsBedrockUsePromptCache === true && {
cache_control: { type: "ephemeral" },
}),
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
...(this.options.awsBedrockUsePromptCache === true && {
cache_control: { type: "ephemeral" },
}),
}
: content,
),
}
}
return message
}),
max_tokens: this.getModel().info.maxTokens || 8192,
temperature: 0,
system: systemPrompt,
messages,
stream: true,
})
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start":
@@ -95,8 +37,6 @@ export class AwsBedrockHandler implements ApiHandler {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
@@ -106,22 +46,9 @@ export class AwsBedrockHandler implements ApiHandler {
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
break
case "redacted_thinking":
// Handle redacted thinking blocks - we still mark it as reasoning
// but note that the content is encrypted
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "text":
if (chunk.index > 0) {
yield {
@@ -138,12 +65,6 @@ export class AwsBedrockHandler implements ApiHandler {
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "text_delta":
yield {
type: "text",
@@ -168,21 +89,12 @@ export class AwsBedrockHandler implements ApiHandler {
}
}
// Default AWS region
private static readonly DEFAULT_REGION = "us-east-1"
/**
* Gets AWS credentials using the provider chain
* Centralizes credential retrieval logic for all AWS services
*/
private async getAwsCredentials(): Promise<{
accessKeyId: string
secretAccessKey: string
sessionToken?: string
}> {
// Create AWS credentials by executing an AWS provider chain
private async getClient(): Promise<AnthropicBedrock> {
// Create AWS credentials by executing a an AWS provider chain exactly as the
// Anthropic SDK does it, by wrapping the default chain into a temporary process
// environment.
const providerChain = fromNodeProviderChain()
return await AwsBedrockHandler.withTempEnv(
const credentials = await AwsBedrockHandler.withTempEnv(
() => {
AwsBedrockHandler.setEnv("AWS_REGION", this.options.awsRegion)
AwsBedrockHandler.setEnv("AWS_ACCESS_KEY_ID", this.options.awsAccessKey)
@@ -192,64 +104,33 @@ export class AwsBedrockHandler implements ApiHandler {
},
() => providerChain(),
)
}
/**
* Gets the AWS region to use, with fallback to default
*/
private getRegion(): string {
return this.options.awsRegion || AwsBedrockHandler.DEFAULT_REGION
}
/**
* Creates a BedrockRuntimeClient with the appropriate credentials
*/
private async getBedrockClient(): Promise<BedrockRuntimeClient> {
const credentials = await this.getAwsCredentials()
return new BedrockRuntimeClient({
region: this.getRegion(),
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken,
},
...(this.options.awsBedrockEndpoint && { endpoint: this.options.awsBedrockEndpoint }),
})
}
/**
* Creates an AnthropicBedrock client with the appropriate credentials
*/
private async getAnthropicClient(): Promise<AnthropicBedrock> {
const credentials = await this.getAwsCredentials()
// Return an AnthropicBedrock client with the resolved/assumed credentials.
//
// When AnthropicBedrock creates its AWS client, the chain will execute very
// fast as the access/secret keys will already be already provided, and have
// a higher precedence than the profiles.
return new AnthropicBedrock({
awsAccessKey: credentials.accessKeyId,
awsSecretKey: credentials.secretAccessKey,
awsSessionToken: credentials.sessionToken,
awsRegion: this.getRegion(),
...(this.options.awsBedrockEndpoint && { baseURL: this.options.awsBedrockEndpoint }),
awsRegion: this.options.awsRegion || "us-east-1",
})
}
/**
* Gets the appropriate model ID, accounting for cross-region inference if enabled
*/
async getModelId(): Promise<string> {
private async getModelId(): Promise<string> {
if (this.options.awsUseCrossRegionInference) {
let regionPrefix = this.getRegion().slice(0, 3)
let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
switch (regionPrefix) {
case "us-":
return `us.${this.getModel().id}`
case "eu-":
return `eu.${this.getModel().id}`
case "ap-":
return `apac.${this.getModel().id}`
break
default:
// cross region inference is not supported in this region, falling back to default model
return this.getModel().id
break
}
}
return this.getModel().id
@@ -266,200 +147,9 @@ export class AwsBedrockHandler implements ApiHandler {
}
}
private static setEnv(key: string, value: string | undefined) {
private static async setEnv(key: string, value: string | undefined) {
if (key !== "" && value !== undefined) {
process.env[key] = value
}
}
/**
* Creates a message using the Deepseek R1 model through AWS Bedrock
*/
private async *createDeepseekMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
modelId: string,
model: { id: BedrockModelId; info: ModelInfo },
): ApiStream {
// Get Bedrock client with proper credentials
const client = await this.getBedrockClient()
// Format prompt for DeepSeek R1 according to documentation
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
// Prepare the request based on DeepSeek R1's expected format
const command = new InvokeModelWithResponseStreamCommand({
modelId: modelId,
contentType: "application/json",
accept: "application/json",
body: JSON.stringify({
prompt: formattedPrompt,
max_tokens: model.info.maxTokens || 8000,
temperature: 0,
}),
})
// Track token usage
const inputTokenEstimate = this.estimateInputTokens(systemPrompt, messages)
let outputTokens = 0
let isFirstChunk = true
let accumulatedTokens = 0
const TOKEN_REPORT_THRESHOLD = 100 // Report usage after accumulating this many tokens
// Execute the streaming request
const response = await client.send(command)
if (response.body) {
for await (const chunk of response.body) {
if (chunk.chunk?.bytes) {
try {
// Parse the response chunk
const decodedChunk = new TextDecoder().decode(chunk.chunk.bytes)
const parsedChunk = JSON.parse(decodedChunk)
// Report usage on first chunk
if (isFirstChunk) {
isFirstChunk = false
const totalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, 0, 0, 0)
yield {
type: "usage",
inputTokens: inputTokenEstimate,
outputTokens: 0,
totalCost: totalCost,
}
}
// Handle DeepSeek R1 response format
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
// For non-streaming response (full response)
const text = parsedChunk.choices[0].text
if (text) {
const chunkTokens = this.estimateTokenCount(text)
outputTokens += chunkTokens
accumulatedTokens += chunkTokens
yield {
type: "text",
text: text,
}
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
accumulatedTokens = 0
}
}
} else if (parsedChunk.delta?.text) {
// For streaming response (delta updates)
const text = parsedChunk.delta.text
const chunkTokens = this.estimateTokenCount(text)
outputTokens += chunkTokens
accumulatedTokens += chunkTokens
yield {
type: "text",
text: text,
}
// Report aggregated token usage only when threshold is reached
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
accumulatedTokens = 0
}
}
} catch (error) {
console.error("Error parsing Deepseek response chunk:", error)
// Propagate the error by yielding a text response with error information
yield {
type: "text",
text: `[ERROR] Failed to parse Deepseek response: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
}
// Report any remaining accumulated tokens at the end of the stream
if (accumulatedTokens > 0) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
}
// Add final total cost calculation that includes both input and output tokens
const finalTotalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, outputTokens, 0, 0)
yield {
type: "usage",
inputTokens: inputTokenEstimate,
outputTokens: outputTokens,
totalCost: finalTotalCost,
}
}
}
/**
* Formats prompt for DeepSeek R1 model according to documentation
* First uses convertToR1Format to merge consecutive messages with the same role,
* then converts to the string format that DeepSeek R1 expects
*/
private formatDeepseekR1Prompt(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
// First use convertToR1Format to merge consecutive messages with the same role
const r1Messages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
// Then convert to the special string format expected by DeepSeek R1
let combinedContent = ""
for (const message of r1Messages) {
let content = ""
if (message.content) {
if (typeof message.content === "string") {
content = message.content
} else {
// Extract text content from message parts
content = message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
}
}
combinedContent += message.role === "user" ? "User: " + content + "\n" : "Assistant: " + content + "\n"
}
// Format according to DeepSeek R1's expected prompt format
return `<begin▁of▁sentence><User>${combinedContent}<Assistant><think>\n`
}
/**
* Estimates token count based on text length (approximate)
* Note: This is a rough estimation, as the actual token count depends on the tokenizer
*/
private estimateInputTokens(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): number {
// For Deepseek R1, we estimate the token count of the formatted prompt
// The formatted prompt includes special tokens and consistent formatting
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
return Math.ceil(formattedPrompt.length / 4)
}
/**
* Estimates token count for a text string
*/
private estimateTokenCount(text: string): number {
// Approximate 4 characters per token
return Math.ceil(text.length / 4)
}
}
-106
View File
@@ -1,106 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
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 { OpenRouterErrorResponse } from "./types"
export class ClineHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.cline.bot/v1",
apiKey: this.options.clineApiKey || "",
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
this.lastGenerationId = undefined
const stream = await createOpenRouterStream(
this.client,
systemPrompt,
messages,
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.thinkingBudgetTokens,
)
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}`)
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
}
const delta = chunk.choices[0]?.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,
}
}
}
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
try {
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
headers: {
Authorization: `Bearer ${this.options.clineApiKey}`,
},
timeout: 15_000, // this request hangs sometimes
})
const generation = response.data
return {
type: "usage",
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
console.error("Error fetching cline generation details:", error)
}
}
return undefined
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.openRouterModelId
const modelInfo = this.options.openRouterModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
}
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
}
}
+9 -33
View File
@@ -3,7 +3,6 @@ import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, DeepSeekModelId, ModelInfo, deepSeekDefaultModelId, deepSeekModels } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
@@ -20,37 +19,6 @@ export class DeepSeekHandler implements ApiHandler {
})
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
// Deepseek reports total input AND cache reads/writes,
// see context caching: https://api-docs.deepseek.com/guides/kv_cache)
// where the input tokens is the sum of the cache hits/misses, just like OpenAI.
// This affects:
// 1) context management truncation algorithm, and
// 2) cost calculation
// Deepseek usage includes extra fields.
// Safely cast the prompt token details section to the appropriate structure.
interface DeepSeekUsage extends OpenAI.CompletionUsage {
prompt_cache_hit_tokens?: number
prompt_cache_miss_tokens?: number
}
const deepUsage = usage as DeepSeekUsage
const inputTokens = deepUsage?.prompt_tokens || 0
const outputTokens = deepUsage?.completion_tokens || 0
const cacheReadTokens = deepUsage?.prompt_cache_hit_tokens || 0
const cacheWriteTokens = deepUsage?.prompt_cache_miss_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
yield {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
@@ -93,7 +61,15 @@ export class DeepSeekHandler implements ApiHandler {
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0, // (deepseek reports total input AND cache reads/writes, see context caching: https://api-docs.deepseek.com/guides/kv_cache) where the input tokens is the sum of the cache hits/misses, while anthropic reports them as separate tokens. This is important to know for 1) context management truncation algorithm, and 2) cost calculation (NOTE: we report both input and cache stats but for now set input price to 0 since all the cost calculation will be done using cache hits/misses)
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-ignore-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
}
}
}
+1 -8
View File
@@ -23,18 +23,11 @@ export class LiteLlmHandler implements ApiHandler {
role: "system",
content: systemPrompt,
}
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
let temperature: number | undefined = 0
if (isOminiModel) {
temperature = undefined // does not support temperature
}
const stream = await this.client.chat.completions.create({
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
messages: [systemMessage, ...formattedMessages],
temperature,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
})
+17 -12
View File
@@ -1,35 +1,40 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Message, Ollama } from "ollama"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { convertToOllamaMessages } from "../transform/ollama-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
export class OllamaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Ollama
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
this.client = new OpenAI({
baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1",
apiKey: "ollama",
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await this.client.chat({
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
messages: ollamaMessages,
messages: openAiMessages,
temperature: 0,
stream: true,
options: {
num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768,
},
})
for await (const chunk of stream) {
if (typeof chunk.message.content === "string") {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: chunk.message.content,
text: delta.content,
}
}
}
+21 -30
View File
@@ -10,7 +10,6 @@ import {
openAiNativeModels,
} from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
@@ -25,47 +24,31 @@ export class OpenAiNativeHandler implements ApiHandler {
})
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
const inputTokens = usage?.prompt_tokens || 0
const outputTokens = usage?.completion_tokens || 0
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0
const cacheWriteTokens = 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
yield {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
switch (model.id) {
switch (this.getModel().id) {
case "o1":
case "o1-preview":
case "o1-mini": {
// o1 doesnt support streaming, non-1 temp, or system prompt
const response = await this.client.chat.completions.create({
model: model.id,
model: this.getModel().id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
})
yield {
type: "text",
text: response.choices[0]?.message.content || "",
}
yield* this.yieldUsage(model.info, response.usage)
yield {
type: "usage",
inputTokens: response.usage?.prompt_tokens || 0,
outputTokens: response.usage?.completion_tokens || 0,
}
break
}
case "o3-mini": {
const stream = await this.client.chat.completions.create({
model: model.id,
model: this.getModel().id,
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
@@ -80,15 +63,18 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.usage) {
// Only last chunk contains usage
yield* this.yieldUsage(model.info, chunk.usage)
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
break
}
default: {
const stream = await this.client.chat.completions.create({
model: model.id,
model: this.getModel().id,
// max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
@@ -104,9 +90,14 @@ export class OpenAiNativeHandler implements ApiHandler {
text: delta.content,
}
}
// contains a null value except for the last chunk which contains the token usage statistics for the entire request
if (chunk.usage) {
// Only last chunk contains usage
yield* this.yieldUsage(model.info, chunk.usage)
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
}
+2 -14
View File
@@ -6,7 +6,6 @@ import { ApiHandler } from "../index"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
export class OpenAiHandler implements ApiHandler {
private options: ApiHandlerOptions
@@ -15,8 +14,7 @@ export class OpenAiHandler implements ApiHandler {
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")) {
if (this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
@@ -34,30 +32,20 @@ export class OpenAiHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.openAiModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
const isO3Mini = modelId.includes("o3-mini")
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
let temperature: number | undefined = this.options.openAiModelInfo?.temperature ?? openAiModelInfoSaneDefaults.temperature
let reasoningEffort: ChatCompletionReasoningEffort | undefined = undefined
if (isDeepseekReasoner) {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (isO3Mini) {
openAiMessages = [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
temperature = undefined // does not support temperature
reasoningEffort = (this.options.o3MiniReasoningEffort as ChatCompletionReasoningEffort) || "medium"
}
const stream = await this.client.chat.completions.create({
model: modelId,
messages: openAiMessages,
temperature,
reasoning_effort: reasoningEffort,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
})
+152 -32
View File
@@ -2,17 +2,16 @@ import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import delay from "delay"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { OpenRouterErrorResponse } from "./types"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
export class OpenRouterHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
@@ -28,29 +27,129 @@ export class OpenRouterHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
this.lastGenerationId = undefined
const model = this.getModel()
const stream = await createOpenRouterStream(
this.client,
systemPrompt,
messages,
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.thinkingBudgetTokens,
)
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// prompt caching: https://openrouter.ai/docs/prompt-caching
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
switch (model.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
openAiMessages[0] = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
},
],
}
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
break
default:
break
}
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
// (models usually default to max tokens allowed)
let maxTokens: number | undefined
switch (model.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
maxTokens = 8_192
break
}
let temperature = 0
let topP: number | undefined = undefined
if (this.getModel().id.startsWith("deepseek/deepseek-r1") || this.getModel().id === "perplexity/sonar-reasoning") {
// Recommended values from DeepSeek
temperature = 0.7
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
if (model.id === "deepseek/deepseek-chat") {
shouldApplyMiddleOutTransform = true
}
// @ts-ignore-next-line
const stream = await this.client.chat.completions.create({
model: model.id,
max_tokens: maxTokens,
temperature: temperature,
top_p: topP,
messages: openAiMessages,
stream: true,
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
include_reasoning: true,
...(model.id === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}),
})
let genId: string | undefined
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"]
const error = chunk.error as { message?: string; code?: number }
console.error(`OpenRouter 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(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
if (!genId && chunk.id) {
genId = chunk.id
}
const delta = chunk.choices[0]?.delta
@@ -63,28 +162,50 @@ export class OpenRouterHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
// console.log("reasoning", delta.reasoning)
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
}
// if (didStreamThinkTagInReasoning) {
// yield {
// type: "text",
// // @ts-ignore-next-line
// text: delta.reasoning,
// }
// } else {
// yield {
// type: "reasoning",
// // @ts-ignore-next-line
// text: delta.reasoning,
// }
// // @ts-ignore-next-line
// reasoningResponse += delta.reasoning
// if (reasoningResponse.includes("</think>")) {
// didStreamThinkTagInReasoning = true
// console.log("did hit think tag", reasoningResponse)
// }
// }
}
// if (chunk.usage) {
// yield {
// type: "usage",
// inputTokens: chunk.usage.prompt_tokens || 0,
// outputTokens: chunk.usage.completion_tokens || 0,
// }
// }
}
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
if (genId) {
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
try {
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
const generationIterator = this.fetchGenerationDetails(genId)
const generation = (await generationIterator.next()).value
// console.log("OpenRouter generation details:", generation)
return {
yield {
type: "usage",
// cacheWriteTokens: 0,
// cacheReadTokens: 0,
@@ -98,7 +219,6 @@ export class OpenRouterHandler implements ApiHandler {
console.error("Error fetching OpenRouter generation details:", error)
}
}
return undefined
}
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
@@ -109,7 +229,7 @@ export class OpenRouterHandler implements ApiHandler {
headers: {
Authorization: `Bearer ${this.options.openRouterApiKey}`,
},
timeout: 15_000, // this request hangs sometimes
timeout: 5_000, // this request hangs sometimes
})
yield response.data?.data
} catch (error) {
+9 -24
View File
@@ -1,16 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import {
ApiHandlerOptions,
ModelInfo,
mainlandQwenModels,
internationalQwenModels,
mainlandQwenDefaultModelId,
internationalQwenDefaultModelId,
MainlandQwenModelId,
InternationalQwenModelId,
} from "../../shared/api"
import { ApiHandlerOptions, QwenModelId, ModelInfo, qwenDefaultModelId, qwenModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
@@ -30,21 +21,15 @@ export class QwenHandler implements ApiHandler {
})
}
getModel(): { id: MainlandQwenModelId | InternationalQwenModelId; info: ModelInfo } {
getModel(): { id: QwenModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
// Branch based on API line to let poor typescript know what to do
if (this.options.qwenApiLine === "china") {
return {
id: (modelId as MainlandQwenModelId) ?? mainlandQwenDefaultModelId,
info: mainlandQwenModels[modelId as MainlandQwenModelId] ?? mainlandQwenModels[mainlandQwenDefaultModelId],
}
} else {
return {
id: (modelId as InternationalQwenModelId) ?? internationalQwenDefaultModelId,
info:
internationalQwenModels[modelId as InternationalQwenModelId] ??
internationalQwenModels[internationalQwenDefaultModelId],
}
if (modelId && modelId in qwenModels) {
const id = modelId as QwenModelId
return { id, info: qwenModels[id] }
}
return {
id: qwenDefaultModelId,
info: qwenModels[qwenDefaultModelId],
}
}
-75
View File
@@ -1,75 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandlerOptions, ModelInfo, SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "../../shared/api"
import { ApiHandler } from "../index"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
export class SambanovaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.sambanova.ai/v1",
apiKey: this.options.sambanovaApiKey,
})
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const modelId = model.id.toLowerCase()
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in sambanovaModels) {
const id = modelId as SambanovaModelId
return { id, info: sambanovaModels[id] }
}
return {
id: sambanovaDefaultModelId,
info: sambanovaModels[sambanovaDefaultModelId],
}
}
}
-22
View File
@@ -1,22 +0,0 @@
// For the following openrouter error type sources, see the docs here:
// https://openrouter.ai/docs/api-reference/errors
export type OpenRouterErrorResponse = {
error: {
message: string
code: number
metadata?: OpenRouterProviderErrorMetadata | OpenRouterModerationErrorMetadata | Record<string, unknown>
}
}
export type OpenRouterProviderErrorMetadata = {
provider_name: string // The name of the provider that encountered the error
raw: unknown // The raw error from the provider
}
export type OpenRouterModerationErrorMetadata = {
reasons: string[] // Why your input was flagged
flagged_input: string // The text segment that was flagged, limited to 100 characters. If the flagged input is longer than 100 characters, it will be truncated in the middle and replaced with ...
provider_name: string // The name of the provider that requested moderation
model_slug: string
}
+135 -223
View File
@@ -4,25 +4,19 @@ import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api"
import { ApiStream } from "../transform/stream"
import { VertexAI } from "@google-cloud/vertexai"
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
export class VertexHandler implements ApiHandler {
private options: ApiHandlerOptions
private clientAnthropic: AnthropicVertex
private clientVertex: VertexAI
private client: AnthropicVertex
constructor(options: ApiHandlerOptions) {
this.options = options
this.clientAnthropic = new AnthropicVertex({
this.client = new AnthropicVertex({
projectId: this.options.vertexProjectId,
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
region: this.options.vertexRegion,
})
this.clientVertex = new VertexAI({
project: this.options.vertexProjectId,
location: this.options.vertexRegion,
})
}
@withRetry()
@@ -30,66 +24,36 @@ export class VertexHandler implements ApiHandler {
const model = this.getModel()
const modelId = model.id
if (modelId.includes("claude")) {
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
let stream
switch (modelId) {
case "claude-3-7-sonnet@20250219":
case "claude-3-5-sonnet-v2@20241022":
case "claude-3-5-sonnet@20240620":
case "claude-3-5-haiku@20241022":
case "claude-3-opus@20240229":
case "claude-3-haiku@20240307": {
// Find indices of user messages for cache control
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
let stream
switch (modelId) {
case "claude-3-7-sonnet@20250219":
case "claude-3-5-sonnet-v2@20241022":
case "claude-3-5-sonnet@20240620":
case "claude-3-5-haiku@20241022":
case "claude-3-opus@20240229":
case "claude-3-haiku@20240307": {
// Find indices of user messages for cache control
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.clientAnthropic.beta.messages.create(
{
model: modelId,
max_tokens: model.info.maxTokens || 8192,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
temperature: reasoningOn ? undefined : 0,
system: [
{
text: systemPrompt,
type: "text",
cache_control: { type: "ephemeral" },
},
],
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
cache_control: {
type: "ephemeral",
},
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
cache_control: {
type: "ephemeral",
},
}
: content,
),
}
}
stream = await this.client.beta.messages.create(
{
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
system: [
{
text: systemPrompt,
type: "text",
cache_control: { type: "ephemeral" },
},
],
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
@@ -98,173 +62,121 @@ export class VertexHandler implements ApiHandler {
{
type: "text",
text: message.content,
cache_control: {
type: "ephemeral",
},
},
]
: message.content,
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
cache_control: {
type: "ephemeral",
},
}
: content,
),
}
}),
stream: true,
},
{
headers: {},
},
)
break
}
default: {
stream = await this.clientAnthropic.beta.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
system: [
{
text: systemPrompt,
type: "text",
},
],
messages: messages.map((message) => ({
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
})),
stream: true,
})
break
}
}
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start":
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
break
case "redacted_thinking":
// Handle redacted thinking blocks - we still mark it as reasoning
// but note that the content is encrypted
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "text":
if (chunk.index > 0) {
yield {
type: "text",
text: "\n",
}
}
yield {
type: "text",
text: chunk.content_block.text,
}
break
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
}
break
case "content_block_stop":
break
}
}
} else {
// gemini
const generativeModel = this.clientVertex.getGenerativeModel({
model: this.getModel().id,
systemInstruction: {
role: "system",
parts: [{ text: systemPrompt }],
},
})
const request = {
contents: [
{
role: "user",
parts: messages.map((m) => {
if (typeof m.content === "string") {
return { text: m.content }
} else if (Array.isArray(m.content)) {
return {
text: m.content
.map((block) => {
if (typeof block === "string") {
return block
} else if (block.type === "text") {
return block.text
} else {
console.log("Unsupported block type", block)
return ""
}
})
.join(" "),
}
} else {
return { text: "" }
}
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
}
}),
stream: true,
},
],
{
headers: {},
},
)
break
}
const streamingResult = await generativeModel.generateContentStream(request)
for await (const chunk of streamingResult.stream) {
// If usage data is available, yield it similarly:
// yield { type: "usage", inputTokens: 0, outputTokens: 0 }
// Otherwise, just yield text:
const candidates = chunk.candidates || []
for (const candidate of candidates) {
for (const part of candidate.content?.parts || []) {
if (part.text) {
default: {
stream = await this.client.beta.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
system: [
{
text: systemPrompt,
type: "text",
},
],
messages: messages.map((message) => ({
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
})),
stream: true,
})
break
}
}
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start":
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
break
case "content_block_start":
switch (chunk.content_block.type) {
case "text":
if (chunk.index > 0) {
yield {
type: "text",
text: "\n",
}
}
yield {
type: "text",
text: part.text,
text: chunk.content_block.text,
}
}
break
}
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
}
break
case "content_block_stop":
break
}
}
}
+96 -1
View File
@@ -1,5 +1,15 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Content, EnhancedGenerateContentResponse, InlineDataPart, Part, TextPart } from "@google/generative-ai"
import {
Content,
EnhancedGenerateContentResponse,
FunctionCallPart,
FunctionDeclaration,
FunctionResponsePart,
InlineDataPart,
Part,
SchemaType,
TextPart,
} from "@google/generative-ai"
export function convertAnthropicContentToGemini(content: string | Anthropic.ContentBlockParam[]): Part[] {
if (typeof content === "string") {
@@ -19,6 +29,55 @@ export function convertAnthropicContentToGemini(content: string | Anthropic.Cont
mimeType: block.source.media_type,
},
} as InlineDataPart
case "tool_use":
return {
functionCall: {
name: block.name,
args: block.input,
},
} as FunctionCallPart
case "tool_result":
const name = block.tool_use_id.split("-")[0]
if (!block.content) {
return []
}
if (typeof block.content === "string") {
return {
functionResponse: {
name,
response: {
name,
content: block.content,
},
},
} as FunctionResponsePart
} else {
// The only case when tool_result could be array is when the tool failed and we're providing ie user feedback potentially with images
const textParts = block.content.filter((part) => part.type === "text")
const imageParts = block.content.filter((part) => part.type === "image")
const text = textParts.length > 0 ? textParts.map((part) => part.text).join("\n\n") : ""
const imageText = imageParts.length > 0 ? "\n\n(See next part for image)" : ""
return [
{
functionResponse: {
name,
response: {
name,
content: text + imageText,
},
},
} as FunctionResponsePart,
...imageParts.map(
(part) =>
({
inlineData: {
data: part.source.data,
mimeType: part.source.media_type,
},
}) as InlineDataPart,
),
]
}
default:
throw new Error(`Unsupported content block type: ${(block as any).type}`)
}
@@ -32,6 +91,26 @@ export function convertAnthropicMessageToGemini(message: Anthropic.Messages.Mess
}
}
export function convertAnthropicToolToGemini(tool: Anthropic.Messages.Tool): FunctionDeclaration {
return {
name: tool.name,
description: tool.description || "",
parameters: {
type: SchemaType.OBJECT,
properties: Object.fromEntries(
Object.entries(tool.input_schema.properties || {}).map(([key, value]) => [
key,
{
type: (value as any).type.toUpperCase(),
description: (value as any).description || "",
},
]),
),
required: (tool.input_schema.required as string[]) || [],
},
}
}
/*
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
*/
@@ -48,6 +127,22 @@ export function convertGeminiResponseToAnthropic(response: EnhancedGenerateConte
content.push({ type: "text", text, citations: null })
}
// Add function calls as tool_use blocks
const functionCalls = response.functionCalls()
if (functionCalls) {
functionCalls.forEach((call, index) => {
if ("content" in call.args && typeof call.args.content === "string") {
call.args.content = unescapeGeminiContent(call.args.content)
}
content.push({
type: "tool_use",
id: `${call.name}-${index}-${Date.now()}`,
name: call.name,
input: call.args,
})
})
}
// Determine stop reason
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null
const finishReason = response.candidates?.[0]?.finishReason
+45 -14
View File
@@ -1,4 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Mistral } from "@mistralai/mistralai"
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
@@ -20,15 +21,25 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
})
} else {
if (anthropicMessage.role === "user") {
// Filter to only include text and image blocks
const textAndImageBlocks = anthropicMessage.content.filter(
(part) => part.type === "text" || part.type === "image",
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
if (textAndImageBlocks.length > 0) {
if (nonToolMessages.length > 0) {
mistralMessages.push({
role: "user",
content: textAndImageBlocks.map((part) => {
content: nonToolMessages.map((part) => {
if (part.type === "image") {
return {
type: "image_url",
@@ -42,17 +53,37 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
})
}
} else if (anthropicMessage.role === "assistant") {
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
if (textBlocks.length > 0) {
const content = textBlocks.map((part) => part.text).join("\n")
mistralMessages.push({
role: "assistant",
content,
})
let content: string | undefined
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
}
return part.text
})
.join("\n")
}
mistralMessages.push({
role: "assistant",
content,
})
}
}
}
-109
View File
@@ -1,109 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Message } from "ollama"
export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
const ollamaMessages: Message[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
ollamaMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
})
} else {
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
let toolResultImages: string[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
return "(see following user message for image)"
}
return part.text
})
.join("\n") ?? ""
}
ollamaMessages.push({
role: "user",
images: toolResultImages.length > 0 ? toolResultImages : undefined,
content: content,
})
})
// Process non-tool messages
if (nonToolMessages.length > 0) {
ollamaMessages.push({
role: "user",
content: nonToolMessages
.map((part) => {
if (part.type === "image") {
return `data:${part.source.media_type};base64,${part.source.data}`
}
return part.text
})
.join("\n"),
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string = ""
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
}
return part.text
})
.join("\n")
}
ollamaMessages.push({
role: "assistant",
content,
})
}
}
}
return ollamaMessages
}
-151
View File
@@ -1,151 +0,0 @@
import { ModelInfo } from "../../shared/api"
import { convertToOpenAiMessages } from "./openai-format"
import { convertToR1Format } from "./r1-format"
import { ApiStream, ApiStreamChunk } from "./stream"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { OpenRouterErrorResponse } from "../providers/types"
export async function createOpenRouterStream(
client: OpenAI,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: string; info: ModelInfo },
o3MiniReasoningEffort?: string,
thinkingBudgetTokens?: number,
) {
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// prompt caching: https://openrouter.ai/docs/prompt-caching
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
switch (model.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
openAiMessages[0] = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
},
],
}
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
break
default:
break
}
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
// (models usually default to max tokens allowed)
let maxTokens: number | undefined
switch (model.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
maxTokens = 8_192
break
}
let temperature: number | undefined = 0
let topP: number | undefined = undefined
if (
model.id.startsWith("deepseek/deepseek-r1") ||
model.id === "perplexity/sonar-reasoning" ||
model.id === "qwen/qwq-32b:free" ||
model.id === "qwen/qwq-32b"
) {
// Recommended values from DeepSeek
temperature = 0.7
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
let reasoning: { max_tokens: number } | undefined = undefined
switch (model.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
let budget_tokens = thinkingBudgetTokens || 0
const reasoningOn = budget_tokens !== 0 ? true : false
if (reasoningOn) {
temperature = undefined // extended thinking does not support non-1 temperature
reasoning = { max_tokens: budget_tokens }
}
break
}
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
if (model.id === "deepseek/deepseek-chat") {
shouldApplyMiddleOutTransform = true
}
// @ts-ignore-next-line
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: maxTokens,
temperature: temperature,
top_p: topP,
messages: openAiMessages,
stream: true,
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
include_reasoning: true,
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
...(reasoning ? { reasoning } : {}),
})
return stream
}
+242 -351
View File
File diff suppressed because it is too large Load Diff
+61 -1
View File
@@ -45,7 +45,6 @@ export const toolParamNames = [
"arguments",
"uri",
"question",
"options",
"response",
"result",
] as const
@@ -59,3 +58,64 @@ export interface ToolUse {
params: Partial<Record<ToolParamName, string>>
partial: boolean
}
export interface ExecuteCommandToolUse extends ToolUse {
name: "execute_command"
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
params: Partial<Pick<Record<ToolParamName, string>, "command" | "requires_approval">>
}
export interface ReadFileToolUse extends ToolUse {
name: "read_file"
params: Partial<Pick<Record<ToolParamName, string>, "path">>
}
export interface WriteToFileToolUse extends ToolUse {
name: "write_to_file"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content">>
}
export interface ReplaceInFileToolUse extends ToolUse {
name: "replace_in_file"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "diff">>
}
export interface SearchFilesToolUse extends ToolUse {
name: "search_files"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>
}
export interface ListFilesToolUse extends ToolUse {
name: "list_files"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "recursive">>
}
export interface ListCodeDefinitionNamesToolUse extends ToolUse {
name: "list_code_definition_names"
params: Partial<Pick<Record<ToolParamName, string>, "path">>
}
export interface BrowserActionToolUse extends ToolUse {
name: "browser_action"
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
}
export interface UseMcpToolToolUse extends ToolUse {
name: "use_mcp_tool"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
}
export interface AccessMcpResourceToolUse extends ToolUse {
name: "access_mcp_resource"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "uri">>
}
export interface AskFollowupQuestionToolUse extends ToolUse {
name: "ask_followup_question"
params: Partial<Pick<Record<ToolParamName, string>, "question">>
}
export interface AttemptCompletionToolUse extends ToolUse {
name: "attempt_completion"
params: Partial<Pick<Record<ToolParamName, string>, "result" | "command">>
}
@@ -1,47 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
export class ContextManager {
getNextTruncationRange(
messages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined = undefined,
keep: "half" | "quarter" = "half",
): [number, number] {
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
const rangeStartIndex = 1
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
let messagesToRemove: number
if (keep === "half") {
// Remove half of user-assistant pairs
messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number
} else {
// Remove 3/4 of user-assistant pairs
messagesToRemove = Math.floor((messages.length - startOfRest) / 8) * 3 * 2
}
let rangeEndIndex = startOfRest + messagesToRemove - 1
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
if (messages[rangeEndIndex].role !== "user") {
rangeEndIndex -= 1
}
// this is an inclusive range that will be removed from the conversation history
return [rangeStartIndex, rangeEndIndex]
}
getTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (!deletedRange) {
return messages
}
const [start, end] = deletedRange
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return [...messages.slice(0, start), ...messages.slice(end + 1)]
}
}
-60
View File
@@ -1,60 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { GlobalFileNames } from "../global-constants"
import Anthropic from "@anthropic-ai/sdk"
import { fileExistsAtPath } from "../utils/fs"
import { ClineMessage } from "../shared/ExtensionMessage"
export async function ensureTaskDirectoryExists(globalStoragePath: string | undefined, taskId: string): Promise<string> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const taskDir = path.join(globalStoragePath, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
return taskDir
}
export async function saveApiConversationHistory(
globalStoragePath: string | undefined,
taskId: string,
apiConversationHistory: Anthropic.MessageParam[],
) {
try {
const filePath = path.join(
await ensureTaskDirectoryExists(globalStoragePath, taskId),
GlobalFileNames.apiConversationHistory,
)
await fs.writeFile(filePath, JSON.stringify(apiConversationHistory))
} catch (error) {
// in the off chance this fails, we don't want to stop the task
console.error("Failed to save API conversation history:", error)
}
}
export async function getSavedApiConversationHistory(
globalStoragePath: string | undefined,
taskId: string,
): Promise<Anthropic.MessageParam[]> {
const filePath = path.join(await ensureTaskDirectoryExists(globalStoragePath, taskId), GlobalFileNames.apiConversationHistory)
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
return []
}
export async function getSavedClineMessages(globalStoragePath: string | undefined, taskId: string): Promise<ClineMessage[]> {
const filePath = path.join(await ensureTaskDirectoryExists(globalStoragePath, taskId), GlobalFileNames.uiMessages)
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
} else {
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(globalStoragePath, taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
}
return []
}
+3 -11
View File
@@ -216,13 +216,9 @@ Usage:
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
Parameters:
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
Usage:
<ask_followup_question>
<question>Your question here</question>
<options>
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</ask_followup_question>
## attempt_completion
@@ -243,13 +239,9 @@ Your final result description here
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
Usage:
<plan_mode_response>
<response>Your response here</response>
<options>
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</plan_mode_response>
# Tool Use Examples
@@ -821,7 +813,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
## Important Considerations
- Using write_to_file requires providing the file's complete final content.
- Using write_to_file requires providing the files complete final content.
- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file.
- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it.
@@ -834,12 +826,12 @@ You have access to two tools for working with files: **write_to_file** and **rep
## When to Use
- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc.
- Targeted improvements where only specific portions of the file's content needs to be altered.
- Targeted improvements where only specific portions of the files content needs to be altered.
- Especially useful for long files where much of the file will remain unchanged.
## Advantages
- More efficient for minor edits, since you don't need to supply the entire file content.
- More efficient for minor edits, since you dont need to supply the entire file content.
- Reduces the chance of errors that can occur when overwriting large files.
# Choosing the Appropriate Tool
+97
View File
@@ -0,0 +1,97 @@
import { Anthropic } from "@anthropic-ai/sdk"
/*
We can't implement a dynamically updating sliding window as it would break prompt cache
every time. To maintain the benefits of caching, we need to keep conversation history
static. This operation should be performed as infrequently as possible. If a user reaches
a 200k context, we can assume that the first half is likely irrelevant to their current task.
Therefore, this function should only be called when absolutely necessary to fit within
context limits, not as a continuous process.
*/
// export function truncateHalfConversation(
// messages: Anthropic.Messages.MessageParam[],
// ): Anthropic.Messages.MessageParam[] {
// // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating.
// // Always keep the first Task message (this includes the project's file structure in environment_details)
// const truncatedMessages = [messages[0]]
// // Remove half of user-assistant pairs
// const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number
// const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use
// truncatedMessages.push(...remainingMessages)
// return truncatedMessages
// }
/*
getNextTruncationRange: Calculates the next range of messages to be "deleted"
- Takes the full messages array and optional current deleted range
- Always preserves the first message (task message)
- Removes 1/2 of remaining messages (rounded down to even number) after current deleted range
- Returns [startIndex, endIndex] representing inclusive range to delete
getTruncatedMessages: Constructs the truncated array using the deleted range
- Takes full messages array and optional deleted range
- Returns new array with messages in deleted range removed
- Preserves order and structure of remaining messages
The range is represented as [startIndex, endIndex] where both indices are inclusive
The functions maintain the original array integrity while allowing progressive truncation
through the deletedRange parameter
Usage example:
const messages = [user1, assistant1, user2, assistant2, user3, assistant3];
let deletedRange = getNextTruncationRange(messages); // [1,2] (assistant1,user2)
let truncated = getTruncatedMessages(messages, deletedRange);
// [user1, assistant2, user3, assistant3]
deletedRange = getNextTruncationRange(messages, deletedRange); // [2,3] (assistant2,user3)
truncated = getTruncatedMessages(messages, deletedRange);
// [user1, assistant3]
*/
export function getNextTruncationRange(
messages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined = undefined,
keep: "half" | "quarter" = "half",
): [number, number] {
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
const rangeStartIndex = 1
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
let messagesToRemove: number
if (keep === "half") {
// Remove half of user-assistant pairs
messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number
} else {
// Remove 3/4 of user-assistant pairs
messagesToRemove = Math.floor((messages.length - startOfRest) / 8) * 3 * 2
}
let rangeEndIndex = startOfRest + messagesToRemove - 1
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
if (messages[rangeEndIndex].role !== "user") {
rangeEndIndex -= 1
}
// this is an inclusive range that will be removed from the conversation history
return [rangeStartIndex, rangeEndIndex]
}
export function getTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (!deletedRange) {
return messages
}
const [start, end] = deletedRange
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return [...messages.slice(0, start), ...messages.slice(end + 1)]
}
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -9,6 +9,7 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import assert from "node:assert"
import { telemetryService } from "./services/telemetry/TelemetryService"
import { CheckpointSettingsManager } from "./integrations/checkpoints/CheckpointSettings"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -32,6 +33,9 @@ export function activate(context: vscode.ExtensionContext) {
const sidebarProvider = new ClineProvider(context, outputChannel)
// Initialize CheckpointSettingsManager
CheckpointSettingsManager.initialize(context.globalStorageUri.fsPath)
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
webviewOptions: { retainContextWhenHidden: true },
@@ -123,6 +127,14 @@ export function activate(context: vscode.ExtensionContext) {
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.openCheckpointsIgnore", async () => {
const settingsManager = CheckpointSettingsManager.getInstance()
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(settingsManager.checkpointsIgnorePath))
await vscode.window.showTextDocument(doc)
}),
)
/*
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
@@ -162,12 +174,10 @@ export function activate(context: vscode.ExtensionContext) {
case "/auth": {
const token = query.get("token")
const state = query.get("state")
const apiKey = query.get("apiKey")
console.log("Auth callback received:", {
token: token,
state: state,
apiKey: apiKey,
})
// Validate state parameter
@@ -176,8 +186,8 @@ export function activate(context: vscode.ExtensionContext) {
return
}
if (token && apiKey) {
await visibleProvider.handleAuthCallback(token, apiKey)
if (token) {
await visibleProvider.handleAuthCallback(token)
}
break
}
-8
View File
@@ -1,8 +0,0 @@
// NOTE: These are here temporarily until we find a better home for them
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
}
@@ -75,10 +75,8 @@ describe("Checkpoint Commit Operations", () => {
expect(sortedDiffs[1].relativePath).to.equal("src/test2.txt")
// Verify file contents
expect(sortedDiffs[0].before).to.equal("file1 initial\n")
expect(sortedDiffs[0].after).to.equal("file1 modified\n")
expect(sortedDiffs[1].before).to.equal("file2 initial\n")
expect(sortedDiffs[1].after).to.equal("file2 modified\n")
expect(sortedDiffs[0].before).to.equal("file1 initial\nfile2 initial\n")
expect(sortedDiffs[0].after).to.equal("file1 modified\nfile2 modified\n")
})
it("should create commit when files are deleted", async () => {
@@ -2,6 +2,7 @@ import fs from "fs/promises"
import { join } from "path"
import { fileExistsAtPath } from "../../utils/fs"
import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
import { CheckpointSettingsManager } from "./CheckpointSettings"
/**
* CheckpointExclusions Module
@@ -31,14 +32,20 @@ import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
*/
/**
* Returns the default list of file and directory patterns to exclude from checkpoints.
* Combines built-in patterns with workspace-specific LFS patterns.
*
* @param lfsPatterns - Optional array of Git LFS patterns from workspace
* @returns Array of glob patterns to exclude
* @todo Make this configurable by the user
* Interface representing the result of a file exclusion check
*/
export const getDefaultExclusions = (lfsPatterns: string[] = []): string[] => [
interface ExclusionResult {
/** Whether the file should be excluded */
excluded: boolean
/** Optional reason for exclusion */
reason?: string
}
/**
* Returns the default list of file and directory patterns to exclude from checkpoints.
* These patterns will be written to .checkpointsignore when it's created.
*/
export const getDefaultExclusions = (): string[] => [
// Build and Development Artifacts
".git/",
`.git${GIT_DISABLED_SUFFIX}/`,
@@ -64,10 +71,51 @@ export const getDefaultExclusions = (lfsPatterns: string[] = []): string[] => [
// Log Files
...getLogFilePatterns(),
...lfsPatterns,
]
/**
* Writes the combined exclusion patterns to Git's exclude file.
* Creates the info directory if it doesn't exist.
*
* @param gitPath - Path to the .git directory
* @param lfsPatterns - Optional array of Git LFS patterns to include
*/
export const writeExcludesFile = async (gitPath: string, lfsPatterns: string[] = []): Promise<void> => {
const excludesPath = join(gitPath, "info", "exclude")
await fs.mkdir(join(gitPath, "info"), { recursive: true })
const settingsManager = CheckpointSettingsManager.getInstance()
// Ensure .checkpointsignore exists and load its patterns
const ignorePatterns = await settingsManager.getIgnorePatterns()
// Combine patterns and write to git exclude file
const patterns = [...ignorePatterns, ...lfsPatterns]
await fs.writeFile(excludesPath, patterns.join("\n"))
}
/**
* Retrieves Git LFS patterns from the workspace's .gitattributes file.
* Returns an empty array if no patterns found or file doesn't exist.
*
* @param workspacePath - Path to the workspace root
* @returns Array of Git LFS patterns found in .gitattributes
*/
export const getLfsPatterns = async (workspacePath: string): Promise<string[]> => {
try {
const attributesPath = join(workspacePath, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
return attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
}
return []
}
/**
* Returns patterns for common build and development artifact directories
* @returns Array of glob patterns for build artifacts
@@ -119,7 +167,7 @@ function getMediaFilePatterns(): string[] {
"*.webp",
"*.tiff",
"*.tif",
// "*.svg",
"*.svg",
"*.raw",
"*.heic",
"*.avif",
@@ -285,41 +333,3 @@ function getGeospatialPatterns(): string[] {
function getLogFilePatterns(): string[] {
return ["*.error", "*.log", "*.logs", "*.npm-debug.log*", "*.out", "*.stdout", "yarn-debug.log*", "yarn-error.log*"]
}
/**
* Writes the combined exclusion patterns to Git's exclude file.
* Creates the info directory if it doesn't exist.
*
* @param gitPath - Path to the .git directory
* @param lfsPatterns - Optional array of Git LFS patterns to include
*/
export const writeExcludesFile = async (gitPath: string, lfsPatterns: string[] = []): Promise<void> => {
const excludesPath = join(gitPath, "info", "exclude")
await fs.mkdir(join(gitPath, "info"), { recursive: true })
const patterns = getDefaultExclusions(lfsPatterns)
await fs.writeFile(excludesPath, patterns.join("\n"))
}
/**
* Retrieves Git LFS patterns from the workspace's .gitattributes file.
* Returns an empty array if no patterns found or file doesn't exist.
*
* @param workspacePath - Path to the workspace root
* @returns Array of Git LFS patterns found in .gitattributes
*/
export const getLfsPatterns = async (workspacePath: string): Promise<string[]> => {
try {
const attributesPath = join(workspacePath, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
return attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
}
return []
}
@@ -1,13 +1,21 @@
import fs from "fs/promises"
import { globby } from "globby"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import { fileExistsAtPath } from "../../utils/fs"
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import fs from "fs/promises"
import * as path from "path"
import { fileExistsAtPath } from "../../utils/fs"
import * as vscode from "vscode"
import { getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
import { HistoryItem } from "../../shared/HistoryItem"
interface StorageProvider {
context: {
globalStorageUri: { fsPath: string }
}
}
interface CheckpointAddResult {
success: boolean
fileCount: number
}
/**
@@ -19,20 +27,25 @@ interface CheckpointAddResult {
* - Git repository initialization and configuration
* - Git settings management (user, LFS, etc.)
* - Worktree configuration and management
* - Task-specific branch management (creation, switching, deletion)
* - Handling of both legacy and branch-per-task checkpoint structures
* - Managing nested git repositories during checkpoint operations
* - File staging and checkpoint creation
* - Shadow git repository maintenance and cleanup
*/
export class GitOperations {
private cwd: string
private isLegacyCheckpoint: boolean
/**
* Creates a new GitOperations instance.
*
* @param cwd - The current working directory for git operations
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
*/
constructor(cwd: string) {
constructor(cwd: string, isLegacyCheckpoint: boolean) {
this.cwd = cwd
this.isLegacyCheckpoint = isLegacyCheckpoint
}
/**
@@ -44,9 +57,12 @@ export class GitOperations {
* - Creates/verifies shadow git repository
* - Configures git settings (user, LFS, etc.)
* - Sets up worktree to point to workspace
* - Creates initial empty commit
* - Handles both legacy and branch-per-task checkpoint structures
*
* @param gitPath - Path to the .git directory
* @param cwd - The current working directory for git operations
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
* @returns Promise<string> Path to the initialized .git directory
* @throws Error if:
* - Worktree verification fails for existing repository
@@ -54,8 +70,8 @@ export class GitOperations {
* - Unable to create initial commit
* - LFS pattern setup fails
*/
public async initShadowGit(gitPath: string, cwd: string, taskId: string): Promise<string> {
console.info(`Initializing shadow git`)
public static async initShadowGit(gitPath: string, cwd: string, isLegacyCheckpoint: boolean): Promise<string> {
console.info(`Initializing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git`)
// If repo exists, just verify worktree
if (await fileExistsAtPath(gitPath)) {
@@ -64,41 +80,33 @@ export class GitOperations {
if (worktree.value !== cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
}
console.warn(`Using existing shadow git at ${gitPath}`)
// shadow git repo already exists, but update the excludes just in case
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
console.warn(`Using existing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git at ${gitPath}`)
return gitPath
}
// Initialize new repo
const startTime = performance.now()
const checkpointsDir = path.dirname(gitPath)
console.warn(`Creating new shadow git in ${checkpointsDir}`)
console.warn(`Creating new ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git in ${checkpointsDir}`)
const git = simpleGit(checkpointsDir)
await git.init()
// Configure repo with git settings
// Configure repo
await git.addConfig("core.worktree", cwd)
await git.addConfig("commit.gpgSign", "false")
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "checkpoint@cline.bot")
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
// Set up LFS patterns
const lfsPatterns = await getLfsPatterns(cwd)
await writeExcludesFile(gitPath, lfsPatterns)
await this.addCheckpointFiles(git)
// Initial commit only on first repo creation
await git.commit("initial commit", { "--allow-empty": null })
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
console.warn(`Shadow git initialization completed`)
console.warn(`${isLegacyCheckpoint ? "Legacy" : "New"} shadow git initialization completed`)
return gitPath
}
@@ -123,6 +131,202 @@ export class GitOperations {
}
}
/**
* Checks if a shadow Git repository exists for the given task and workspace.
* Checks both legacy checkpoint paths (tasks/{taskId}/checkpoints/.git) and
* branch-per-task paths (checkpoints/{workspaceHash}/.git).
*
* @param taskId - The ID of the task whose shadow git to check
* @param provider - The ClineProvider instance for accessing VS Code functionality
* @returns Promise<boolean> True if either a legacy or branch-per-task shadow git exists, false otherwise
*/
public static async doesShadowGitExist(taskId: string, provider?: StorageProvider): Promise<boolean> {
const globalStoragePath = provider?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
return false
}
// Check legacy checkpoint path to see if this is a legacy task
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
if (await fileExistsAtPath(legacyGitPath)) {
console.info("Found legacy shadow git")
return true
}
// Check branch-per-task path for newer tasks
const workingDir = await getWorkingDirectory()
const cwdHash = hashWorkingDir(workingDir)
const gitPath = path.join(globalStoragePath, "checkpoints", cwdHash, ".git")
const exists = await fileExistsAtPath(gitPath)
if (exists) {
console.info("Found branch-per-task shadow git")
}
return exists
}
/**
* Deletes a branch in the git repository, handling cases where the branch is currently checked out.
* If the branch to be deleted is currently checked out, the method will:
* 1. Save the current worktree configuration
* 2. Temporarily unset the worktree to prevent workspace modifications
* 3. Force switch to master/main branch
* 4. Delete the target branch
* 5. Restore the worktree configuration
*
* @param git - SimpleGit instance to use for operations
* @param branchName - Name of the branch to delete
* @param checkpointsDir - Directory containing the git repository
* @throws Error if:
* - Branch deletion fails
* - Unable to switch to master/main branch after 3 retries
* - Git operations fail during the process
*/
public static async deleteBranchForGit(git: SimpleGit, branchName: string, checkpointsDir: string): Promise<void> {
// Check if branch exists
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.error(`Task branch ${branchName} does not exist, nothing to delete`)
return // Branch doesn't exist, nothing to delete
}
// First, if we're on the branch to be deleted, switch to master/main
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Current branch: ${currentBranch}, target branch to delete: ${branchName}`)
if (currentBranch === branchName) {
console.debug("Currently on branch to be deleted, switching to master/main first")
// Save the current worktree config
const worktree = await git.getConfig("core.worktree")
console.debug(`Saved current worktree config: ${worktree.value}`)
try {
// Temporarily unset worktree to prevent workspace modifications
console.debug("Temporarily unsetting worktree config")
await git.raw(["config", "--unset", "core.worktree"])
// Force discard all changes
console.debug("Discarding all changes")
await git.reset(["--hard"])
await git.clean("f", ["-d"]) // Clean mode 'f' for force, -d for directories
// Determine default branch (master or main)
const defaultBranch = branches.all.includes("main") ? "main" : "master"
console.debug(`Using ${defaultBranch} as default branch`)
// Switch to default branch and delete branch
console.debug(`Attempting to force switch to ${defaultBranch} branch`)
await git.checkout([defaultBranch, "--force"])
// Verify the switch completed
let retries = 3
while (retries > 0) {
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.debug(`Verifying branch switch - current branch: ${newBranch}, attempts left: ${retries}`)
if (newBranch === defaultBranch) {
console.debug(`Successfully switched to ${defaultBranch} branch`)
break
}
retries--
if (retries === 0) {
throw new Error(`Failed to switch to ${defaultBranch} branch`)
}
}
console.info(`Deleting branch: ${branchName}`)
await git.raw(["branch", "-D", branchName])
console.debug(`Successfully deleted branch: ${branchName}`)
} finally {
// Restore the worktree config
if (worktree.value) {
console.debug(`Restoring worktree config to: ${worktree.value}`)
await git.addConfig("core.worktree", worktree.value)
}
}
} else {
// If we're not on the branch, we can safely delete it
console.info(`Directly deleting branch ${branchName} since we're not on it`)
await git.raw(["branch", "-D", branchName])
console.debug(`Successfully deleted branch: ${branchName}`)
}
}
/**
* Static method to delete a task's branch using stored workspace path.
* Handles both branch-per-task and legacy checkpoint formats:
* 1. First attempts to delete branch-per-task checkpoint if it exists
* 2. Falls back to deleting legacy checkpoint directory if found
*
* @param taskId - The ID of the task whose branch should be deleted
* @param historyItem - The history item containing the shadow git config
* @param globalStoragePath - Path to VS Code's global storage
* @throws Error if:
* - Global storage path is invalid
* - Branch deletion fails
* - Legacy checkpoint directory deletion fails
*/
public static async deleteTaskBranchStatic(
taskId: string,
historyItem: HistoryItem,
globalStoragePath: string,
): Promise<void> {
try {
console.debug("Starting static task branch deletion process...")
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
// First try to handle branch-per-task checkpoint
let workingDir: string
if (historyItem.shadowGitConfigWorkTree) {
workingDir = historyItem.shadowGitConfigWorkTree
} else {
// Try to determine working directory from current state
workingDir = await getWorkingDirectory()
}
const cwdHash = hashWorkingDir(workingDir)
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
const gitPath = path.join(checkpointsDir, ".git")
if (await fileExistsAtPath(gitPath)) {
console.debug(`Found branch-per-task git repository at ${gitPath}`)
const git = simpleGit(path.dirname(gitPath))
const branchName = `task-${taskId}`
// Check if the branch exists
const branches = await git.branchLocal()
if (branches.all.includes(branchName)) {
console.info(`Found branch ${branchName} to delete`)
await GitOperations.deleteBranchForGit(git, branchName, checkpointsDir)
return
}
console.warn(`Branch ${branchName} not found in branch-per-task repository`)
}
// Only check legacy checkpoint if we didn't find/delete a branch-per-task branch
const legacyCheckpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
const legacyGitPath = path.join(legacyCheckpointsDir, ".git")
if (await fileExistsAtPath(legacyGitPath)) {
console.info("Found legacy checkpoint, deleting directory")
try {
await fs.rm(legacyCheckpointsDir, { recursive: true, force: true })
console.debug("Successfully deleted legacy checkpoint directory")
return
} catch (error) {
console.error("Failed to delete legacy checkpoint directory:", error)
throw error
}
}
console.info("No checkpoints found to delete")
} catch (error) {
console.error("Failed to delete task branch:", error)
throw new Error(`Failed to delete task branch: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
* requirement of using submodules for nested repos.
@@ -134,17 +338,29 @@ export class GitOperations {
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
* @throws Error if renaming any .git directory fails
*/
public async renameNestedGitRepos(disable: boolean) {
// Find all .git directories that are not at the root level
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
cwd: this.cwd,
onlyDirectories: true,
ignore: [".git"], // Ignore root level .git
dot: true,
markDirectories: false,
})
public async renameNestedGitRepos(disable: boolean): Promise<void> {
// Find all .git directories that are not at the root level using VS Code API
const gitFiles = await vscode.workspace.findFiles(
new vscode.RelativePattern(this.cwd, "**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX)),
new vscode.RelativePattern(this.cwd, ".git/**"), // Exclude root .git
)
// For each nested .git directory, rename it based on operation
// Filter to only include directories
const gitPaths: string[] = []
for (const file of gitFiles) {
const relativePath = path.relative(this.cwd, file.fsPath)
try {
const stats = await fs.stat(path.join(this.cwd, relativePath))
if (stats.isDirectory()) {
gitPaths.push(relativePath)
}
} catch {
// Skip if stat fails
continue
}
}
// For each nested .git directory, rename it based on the disable flag
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
@@ -156,13 +372,52 @@ export class GitOperations {
try {
await fs.rename(fullPath, newPath)
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
console.info(`${disable ? "Disabled" : "Enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
console.error(`Failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
/**
* Switches to or creates a task-specific branch in the shadow Git repository.
* For legacy checkpoints, this is a no-op since they use separate repositories.
* For branch-per-task checkpoints, this ensures we're on the correct task branch before operations.
*
* The method performs the following:
* 1. Gets the shadow git path and initializes simple-git
* 2. Constructs the branch name using the task ID
* 3. Checks if the branch exists:
* - If not, creates a new branch
* - If yes, switches to the existing branch
* 4. Verifies the branch switch completed successfully
*
* Branch naming convention:
* task-{taskId}
*
* @param taskId - The ID of the task whose branch to switch to
* @param gitPath - Path to the .git directory
* @returns Promise<void>
* @throws Error if branch operations fail or git commands error
*/
public async switchToTaskBranch(taskId: string, gitPath: string): Promise<void> {
const git = simpleGit(path.dirname(gitPath))
const branchName = `task-${taskId}`
// Create new task-specific branch, or switch to one if it already exists.
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.info(`Creating new task branch: ${branchName}`)
await git.checkoutLocalBranch(branchName)
} else {
console.info(`Switching to existing task branch: ${branchName}`)
await git.checkout(branchName)
}
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Current Checkpoint branch after switch: ${currentBranch}`)
}
/**
* Adds files to the shadow git repository while handling nested git repos.
* Uses git commands to list files and stages them for commit.
@@ -176,6 +431,7 @@ export class GitOperations {
* 5. Re-enables nested git repos
*
* @param git - SimpleGit instance configured for the shadow git repo
* @param gitPath - Path to the .git directory
* @returns Promise<CheckpointAddResult> Object containing success status, message, and file count
* @throws Error if:
* - File operations fail
@@ -183,18 +439,33 @@ export class GitOperations {
* - LFS pattern updates fail
* - Nested git repo handling fails
*/
public async addCheckpointFiles(git: SimpleGit): Promise<CheckpointAddResult> {
const startTime = performance.now()
public async addCheckpointFiles(git: SimpleGit, gitPath: string): Promise<CheckpointAddResult> {
try {
// Update exclude patterns before each commit
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
await this.renameNestedGitRepos(true)
console.info("Starting checkpoint add operation...")
//console.info("Starting checkpoint add operation...")
// Get list of all files git would track (respects .gitignore)
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
const gitFiles = (await git.raw(["ls-files", "--others", "--exclude-standard", "--cached"]))
.split("\n")
.filter(Boolean)
// Add filtered files
if (gitFiles.length === 0) {
console.info("No files to add to checkpoint")
return { success: true, fileCount: 0 }
}
try {
await git.add(".")
const durationMs = Math.round(performance.now() - startTime)
console.debug(`Checkpoint add operation completed in ${durationMs}ms`)
return { success: true }
console.info(`Adding ${gitFiles.length} files to checkpoint`)
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
await git.add(gitFiles)
console.info("Checkpoint add operation completed successfully")
return { success: true, fileCount: gitFiles.length }
} catch (error) {
console.error("Checkpoint add operation failed:", error)
throw error
@@ -1,71 +0,0 @@
import fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { fileExistsAtPath } from "../../utils/fs"
/**
* Cleans up legacy checkpoints from task folders.
* This is a one-time operation that runs when the extension is updated to use the new checkpoint system.
*
* @param globalStoragePath - Path to the extension's global storage
* @param outputChannel - VSCode output channel for logging
*/
export async function cleanupLegacyCheckpoints(globalStoragePath: string, outputChannel: vscode.OutputChannel): Promise<void> {
try {
outputChannel.appendLine("Checking for legacy checkpoints...")
const tasksDir = path.join(globalStoragePath, "tasks")
// Check if tasks directory exists
if (!(await fileExistsAtPath(tasksDir))) {
return // No tasks directory, nothing to clean up
}
// Get all task folders
const taskFolders = await fs.readdir(tasksDir)
if (taskFolders.length === 0) {
return // No task folders, nothing to clean up
}
// Get stats for each folder to sort by creation time
const folderStats = await Promise.all(
taskFolders.map(async (folder) => {
const folderPath = path.join(tasksDir, folder)
const stats = await fs.stat(folderPath)
return { folder, path: folderPath, stats }
}),
)
// Sort by creation time, newest first
folderStats.sort((a, b) => b.stats.birthtimeMs - a.stats.birthtimeMs)
// Check if the most recent task folder has a checkpoints directory
if (folderStats.length > 0) {
const mostRecentFolder = folderStats[0]
const checkpointsDir = path.join(mostRecentFolder.path, "checkpoints")
if (await fileExistsAtPath(checkpointsDir)) {
outputChannel.appendLine("Found legacy checkpoints directory, cleaning up...")
// Legacy checkpoints found, delete checkpoints directories in all task folders
for (const folder of folderStats) {
const folderCheckpointsDir = path.join(folder.path, "checkpoints")
if (await fileExistsAtPath(folderCheckpointsDir)) {
outputChannel.appendLine(`Deleting legacy checkpoints in ${folder.folder}`)
try {
await fs.rm(folderCheckpointsDir, { recursive: true, force: true })
} catch (error) {
// Ignore error if directory removal fails
outputChannel.appendLine(`Warning: Failed to delete checkpoints in ${folder.folder}, continuing...`)
}
}
}
outputChannel.appendLine("Legacy checkpoints cleanup completed")
}
}
} catch (error) {
outputChannel.appendLine(`Error cleaning up legacy checkpoints: ${error}`)
console.error("Error cleaning up legacy checkpoints:", error)
}
}
@@ -0,0 +1,203 @@
import fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { fileExistsAtPath } from "../../utils/fs"
import { getDefaultExclusions } from "./CheckpointExclusions"
import { CheckpointSettings } from "../../shared/Checkpoints"
/**
* CheckpointSettings Module
*
* Manages user-configurable settings for the Checkpoints system. Key features:
*
* Settings Management:
* - Enable/disable checkpoints functionality
*
* File Exclusions Management:
* - .checkpointsignore file for exclusion patterns
* - Default patterns for common file types
* - User-customizable pattern list
*
* Storage Structure:
* - Settings stored in globalStorage/settings/cline_checkpoints_settings.json
* - Ignore patterns stored in globalStorage/settings/.checkpointsignore
*
* Integration Points:
* - Used by CheckpointTracker for file filtering
* - Consumed by CheckpointExclusions for pattern management
*/
/**
* Default settings values.
* These are used when no settings file exists or when reading fails.
*/
const DEFAULT_SETTINGS: CheckpointSettings = {
enableCheckpoints: true, // Enabled by default
}
/**
* CheckpointSettingsManager Class
*
* Handles all checkpoint settings operations including:
* - Reading and writing settings to disk
* - Managing .checkpointsignore patterns
* - Providing default values when needed
*
* File Structure:
* globalStorage/
* settings/
* cline_checkpoints_settings.json - Contains enable flag
* .checkpointsignore - Contains file exclusion patterns
*/
export class CheckpointSettingsManager {
public readonly settingsDir: string
public readonly checkpointSettingsPath: string
public readonly checkpointsIgnorePath: string
private settings: CheckpointSettings = DEFAULT_SETTINGS
private static instance: CheckpointSettingsManager | null = null
/**
* Creates a new CheckpointSettingsManager instance.
* Initializes paths for settings and ignore files.
*
* @param globalStoragePath - VS Code's global storage path for the extension
*/
private constructor(globalStoragePath: string) {
this.settingsDir = path.join(globalStoragePath, "settings")
this.checkpointSettingsPath = path.join(this.settingsDir, "cline_checkpoints_settings.json")
this.checkpointsIgnorePath = path.join(this.settingsDir, ".checkpointsignore")
this.readSettings().then((settings) => {
this.settings = settings
this.migrateEnableCheckpointsSetting()
})
this.ensureIgnoreFileExists()
}
/**
* Initialize the singleton instance
*/
public static initialize(globalStoragePath: string): void {
if (!CheckpointSettingsManager.instance) {
CheckpointSettingsManager.instance = new CheckpointSettingsManager(globalStoragePath)
}
}
/**
* Get the singleton instance
*/
public static getInstance(): CheckpointSettingsManager {
if (!CheckpointSettingsManager.instance) {
throw new Error("CheckpointSettingsManager not initialized")
}
return CheckpointSettingsManager.instance
}
/**
* Retrieves current checkpoint settings from memory.
*
* @returns CheckpointSettings Current settings
*/
getSettings(): CheckpointSettings {
return this.settings
}
/**
* Reads checkpoint settings from disk.
* Merges stored settings with defaults to ensure all fields are present.
*
* @returns Promise<CheckpointSettings> Settings read from disk, with defaults for any missing values
*/
private async readSettings(): Promise<CheckpointSettings> {
try {
if (await fileExistsAtPath(this.checkpointSettingsPath)) {
const settingsContent = await fs.readFile(this.checkpointSettingsPath, "utf8")
return { ...DEFAULT_SETTINGS, ...JSON.parse(settingsContent) }
}
// If file doesn't exist, create it with default settings
await this.saveSettings(DEFAULT_SETTINGS)
} catch (error) {
console.error("Error reading checkpoint settings:", error)
}
return DEFAULT_SETTINGS
}
/**
* Saves checkpoint settings to disk and updates in-memory settings.
* Creates settings directory if it doesn't exist.
* Merges new settings with existing ones.
*
* @param settings - Partial settings to update
*/
async saveSettings(settings: Partial<CheckpointSettings>): Promise<void> {
// Ensure settings directory exists
await fs.mkdir(this.settingsDir, { recursive: true })
// Merge with current settings
const updatedSettings = { ...this.settings, ...settings }
this.settings = updatedSettings
// Save to disk
await fs.writeFile(this.checkpointSettingsPath, JSON.stringify(updatedSettings, null, 2))
}
/**
* Retrieves patterns from .checkpointsignore file.
* Filters out empty lines and comments.
*
* @returns Promise<string[]> Array of active ignore patterns
*/
async getIgnorePatterns(): Promise<string[]> {
try {
if (await fileExistsAtPath(this.checkpointsIgnorePath)) {
const content = await fs.readFile(this.checkpointsIgnorePath, "utf8")
return content.split("\n").filter((line) => line.trim() && !line.startsWith("#"))
}
} catch (error) {
console.error("Error loading .checkpointsignore:", error)
}
return []
}
/**
* Ensures .checkpointsignore file exists.
* Creates it with default patterns if it doesn't exist.
*/
private async ensureIgnoreFileExists(): Promise<void> {
try {
await fs.mkdir(this.settingsDir, { recursive: true })
if (!(await fileExistsAtPath(this.checkpointsIgnorePath))) {
await fs.writeFile(this.checkpointsIgnorePath, getDefaultExclusions().join("\n"))
}
} catch (error) {
console.error("Error creating .checkpointsignore:", error)
}
}
/**
* Migrates the enableCheckpoints setting from VSCode configuration to settings file
* All checkpoints settings will be kept in the CheckpointSettingsView from now on
*/
private async migrateEnableCheckpointsSetting(): Promise<void> {
const config = vscode.workspace.getConfiguration("cline")
const enableCheckpoints = config.get<boolean>("enableCheckpoints")
if (enableCheckpoints !== undefined) {
// Save to settings file
await this.saveSettings({
enableCheckpoints,
})
// Remove from VSCode configuration
await config.update("enableCheckpoints", undefined, true)
}
}
/**
* Reinitializes the settings manager by reading settings from disk.
* This should be called when a new CheckpointTracker is created.
*/
async reinitialize(): Promise<void> {
this.settings = await this.readSettings()
}
}
@@ -1,420 +0,0 @@
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import * as vscode from "vscode"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { fileExistsAtPath } from "../../utils/fs"
import { globby } from "globby"
class CheckpointTracker {
private providerRef: WeakRef<ClineProvider>
private taskId: string
private disposables: vscode.Disposable[] = []
private cwd: string
private lastRetrievedShadowGitConfigWorkTree?: string
lastCheckpointHash?: string
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
this.providerRef = new WeakRef(provider)
this.taskId = taskId
this.cwd = cwd
}
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
try {
if (!provider) {
throw new Error("Provider is required to create a checkpoint tracker")
}
// Check if checkpoints are disabled in VS Code settings
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
if (!enableCheckpoints) {
return undefined // Don't create tracker when disabled
}
// Check if git is installed by attempting to get version
try {
await simpleGit().version()
} catch (error) {
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
}
const cwd = await CheckpointTracker.getWorkingDirectory()
const newTracker = new CheckpointTracker(provider, taskId, cwd)
await newTracker.initShadowGit()
return newTracker
} catch (error) {
console.error("Failed to create CheckpointTracker:", error)
throw error
}
}
private static async getWorkingDirectory(): Promise<string> {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
switch (cwd) {
case homedir:
throw new Error("Cannot use checkpoints in home directory")
case desktopPath:
throw new Error("Cannot use checkpoints in Desktop directory")
case documentsPath:
throw new Error("Cannot use checkpoints in Documents directory")
case downloadsPath:
throw new Error("Cannot use checkpoints in Downloads directory")
default:
return cwd
}
}
private async getShadowGitPath(): Promise<string> {
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
await fs.mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
const globalStoragePath = provider?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
return false
}
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
return await fileExistsAtPath(gitPath)
}
public async initShadowGit(): Promise<string> {
const gitPath = await this.getShadowGitPath()
if (await fileExistsAtPath(gitPath)) {
// Make sure it's the same cwd as the configured worktree
const worktree = await this.getShadowGitConfigWorkTree()
if (worktree !== this.cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
}
return gitPath
} else {
const checkpointsDir = path.dirname(gitPath)
const git = simpleGit(checkpointsDir)
await git.init()
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
// Disable commit signing for shadow repo
await git.addConfig("commit.gpgSign", "false")
// Get LFS patterns from workspace if they exist
let lfsPatterns: string[] = []
try {
const attributesPath = path.join(this.cwd, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
lfsPatterns = attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
}
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
// TODO: let user customize these
const excludesPath = path.join(gitPath, "info", "exclude")
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
await fs.writeFile(
excludesPath,
[
".git/", // ignore the user's .git
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
".DS_Store",
"*.log",
"node_modules/",
"__pycache__/",
"env/",
"venv/",
"target/dependency/",
"build/dependencies/",
"dist/",
"out/",
"bundle/",
"vendor/",
"tmp/",
"temp/",
"deps/",
"pkg/",
"Pods/",
// Media files
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
// "*.svg",
"*.mp3",
"*.mp4",
"*.wav",
"*.avi",
"*.mov",
"*.wmv",
"*.webm",
"*.webp",
"*.m4a",
"*.flac",
// Build and dependency directories
"build/",
"bin/",
"obj/",
".gradle/",
".idea/",
".vscode/",
".vs/",
"coverage/",
".next/",
".nuxt/",
// Cache and temporary files
"*.cache",
"*.tmp",
"*.temp",
"*.swp",
"*.swo",
"*.pyc",
"*.pyo",
".pytest_cache/",
".eslintcache",
// Environment and config files
".env*",
"*.local",
"*.development",
"*.production",
// Large data files
"*.zip",
"*.tar",
"*.gz",
"*.rar",
"*.7z",
"*.iso",
"*.bin",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
// Database files
"*.sqlite",
"*.db",
"*.sql",
// Log files
"*.logs",
"*.error",
"npm-debug.log*",
"yarn-debug.log*",
"yarn-error.log*",
...lfsPatterns,
].join("\n"),
)
// Set up git identity (git throws an error if user.name or user.email is not set)
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "noreply@example.com")
await this.addAllFiles(git)
// Initial commit (--allow-empty ensures it works even with no files)
await git.commit("initial commit", { "--allow-empty": null })
return gitPath
}
}
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
if (this.lastRetrievedShadowGitConfigWorkTree) {
return this.lastRetrievedShadowGitConfigWorkTree
}
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
return this.lastRetrievedShadowGitConfigWorkTree
} catch (error) {
console.error("Failed to get shadow git config worktree:", error)
return undefined
}
}
public async commit(): Promise<string | undefined> {
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
await this.addAllFiles(git)
const result = await git.commit("checkpoint", {
"--allow-empty": null,
})
const commitHash = result.commit || ""
this.lastCheckpointHash = commitHash
return commitHash
} catch (error) {
console.error("Failed to create checkpoint:", error)
return undefined
}
}
public async resetHead(commitHash: string): Promise<void> {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
// Clean working directory and force reset
// This ensures that the operation will succeed regardless of:
// - Untracked files in the workspace
// - Staged changes
// - Unstaged changes
// - Partial commits
// - Merge conflicts
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
await git.reset(["--hard", commitHash]) // Hard reset to target commit
}
/**
* Return an array describing changed files between one commit and either:
* - another commit, or
* - the current working directory (including uncommitted changes).
*
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
* If you want truly untracked files to appear, `git add` them first.
*
* @param lhsHash - The commit to compare from (older commit)
* @param rhsHash - The commit to compare to (newer commit).
* If omitted, we compare to the working directory.
* @returns Array of file changes with before/after content
*/
public async getDiffSet(
lhsHash?: string,
rhsHash?: string,
): Promise<
Array<{
relativePath: string
absolutePath: string
before: string
after: string
}>
> {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
// If lhsHash is missing, use the initial commit of the repo
let baseHash = lhsHash
if (!baseHash) {
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
baseHash = rootCommit.trim()
}
// Stage all changes so that untracked files appear in diff summary
await this.addAllFiles(git)
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
// For each changed file, gather before/after content
const result = []
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
for (const file of diffSummary.files) {
const filePath = file.file
const absolutePath = path.join(cwdPath, filePath)
let beforeContent = ""
try {
beforeContent = await git.show([`${baseHash}:${filePath}`])
} catch (_) {
// file didn't exist in older commit => remains empty
}
let afterContent = ""
if (rhsHash) {
// if user provided a newer commit, use git.show at that commit
try {
afterContent = await git.show([`${rhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in newer commit => remains empty
}
} else {
// otherwise, read from disk (includes uncommitted changes)
try {
afterContent = await fs.readFile(absolutePath, "utf8")
} catch (_) {
// file might be deleted => remains empty
}
}
result.push({
relativePath: filePath,
absolutePath,
before: beforeContent,
after: afterContent,
})
}
return result
}
private async addAllFiles(git: SimpleGit) {
await this.renameNestedGitRepos(true)
try {
await git.add(".")
} catch (error) {
console.error("Failed to add files to git:", error)
} finally {
await this.renameNestedGitRepos(false)
}
}
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
private async renameNestedGitRepos(disable: boolean) {
// Find all .git directories that are not at the root level
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
cwd: this.cwd,
onlyDirectories: true,
ignore: [".git"], // Ignore root level .git
dot: true,
markDirectories: false,
})
// For each nested .git directory, rename it based on operation
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
}
try {
await fs.rename(fullPath, newPath)
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
}
}
const GIT_DISABLED_SUFFIX = "_disabled"
export default CheckpointTracker
+210 -113
View File
@@ -1,10 +1,11 @@
import fs from "fs/promises"
import * as path from "path"
import simpleGit from "simple-git"
import simpleGit, { type SimpleGit } from "simple-git"
import * as vscode from "vscode"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { HistoryItem } from "../../shared/HistoryItem"
import { GitOperations } from "./CheckpointGitOperations"
import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
import { getShadowGitPath, hashWorkingDir, getWorkingDirectory, detectLegacyCheckpoint } from "./CheckpointUtils"
import { CheckpointSettingsManager } from "./CheckpointSettings"
/**
* CheckpointTracker Module
@@ -33,9 +34,10 @@ import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./Checkpo
* - Handles cleanup and resource disposal
*
* Checkpoint Architecture:
* - Unique shadow git repository for each workspace
* - Workspaces are identified by name, and hashed to a unique number
* - All commits for a workspace are stored in one shadow git, under a single branch
* - Uses a branch-per-task model to consolidate shadow git repositories
* - Each task gets its own branch within a single shadow git per workspace
* - Maintains backward compatibility with legacy checkpoint structure
* - Automatically cleans up by deleting task branches when tasks are removed
*/
class CheckpointTracker {
@@ -44,6 +46,8 @@ class CheckpointTracker {
private cwd: string
private cwdHash: string
private lastRetrievedShadowGitConfigWorkTree?: string
private lastCheckpointHash?: string
private isLegacyCheckpoint: boolean = false
private gitOperations: GitOperations
/**
@@ -59,12 +63,12 @@ class CheckpointTracker {
this.taskId = taskId
this.cwd = cwd
this.cwdHash = cwdHash
this.gitOperations = new GitOperations(cwd)
this.gitOperations = new GitOperations(cwd, false) // Initialize with non-legacy mode
}
/**
* Creates a new CheckpointTracker instance for tracking changes in a task.
* Handles initialization of the shadow git repository.
* Handles initialization of the shadow git repository and branch setup.
*
* @param taskId - Unique identifier for the task to track
* @param globalStoragePath - the globalStorage path
@@ -78,9 +82,13 @@ class CheckpointTracker {
* Key operations:
* - Validates git installation and settings
* - Creates/initializes shadow git repository
* - Detects and handles legacy checkpoint structure
* - Sets up task-specific branch for new checkpoints
*
* Configuration:
* - Respects 'cline.enableCheckpoints' VS Code setting
* - Uses settings from CheckpointSettingsManager
* - Uses branch-per-task architecture for new checkpoints
* - Maintains backwards compatibility with legacy structure
*/
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
if (!globalStoragePath) {
@@ -88,11 +96,14 @@ class CheckpointTracker {
}
try {
console.info(`Creating new CheckpointTracker for task ${taskId}`)
const startTime = performance.now()
// Check if checkpoints are disabled in VS Code settings
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
if (!enableCheckpoints) {
// Get settings manager instance and reinitialize
const settingsManager = CheckpointSettingsManager.getInstance()
await settingsManager.reinitialize()
// Check if checkpoints are enabled in settings
const settings = settingsManager.getSettings()
if (!settings.enableCheckpoints) {
return undefined // Don't create tracker when disabled
}
@@ -109,12 +120,30 @@ class CheckpointTracker {
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
const gitPath = await getShadowGitPath(newTracker.globalStoragePath, newTracker.taskId, newTracker.cwdHash)
await newTracker.gitOperations.initShadowGit(gitPath, workingDir, taskId)
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
// Check if this is a legacy task
newTracker.isLegacyCheckpoint = await detectLegacyCheckpoint(newTracker.globalStoragePath, newTracker.taskId)
if (newTracker.isLegacyCheckpoint) {
console.debug("Using legacy checkpoint path structure")
const gitPath = await getShadowGitPath(
newTracker.globalStoragePath,
newTracker.taskId,
newTracker.cwdHash,
newTracker.isLegacyCheckpoint,
)
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
return newTracker
}
// Branch-per-task structure
const gitPath = await getShadowGitPath(
newTracker.globalStoragePath,
newTracker.taskId,
newTracker.cwdHash,
newTracker.isLegacyCheckpoint,
)
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
return newTracker
} catch (error) {
console.error("Failed to create CheckpointTracker:", error)
@@ -127,56 +156,69 @@ class CheckpointTracker {
*
* Key behaviors:
* - Creates commit with checkpoint files in shadow git repo
* - Handles both legacy and branch-per-task checkpoint structures
* - For new tasks, switches to task-specific branch first
* - Caches the created commit hash
*
* Commit structure:
* - Commit message: "checkpoint-{cwdHash}-{taskId}"
* - Legacy: Simple "checkpoint" message
* - Branch-per-task: "checkpoint-{cwdHash}-{taskId}"
* - Always allows empty commits
*
* Dependencies:
* - Requires initialized shadow git (getShadowGitPath)
* - Uses addCheckpointFiles to stage changes using 'git add .'
* - Relies on git's native exclusion handling via the exclude file
* - For new checkpoints, requires task branch setup
* - Uses addCheckpointFiles to stage changes
*
* @returns Promise<string | undefined> The created commit hash, or undefined if:
* - Shadow git access fails
* - Branch switch fails
* - Staging files fails
* - Commit creation fails
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Switch branches
* - Stage or commit files
*/
public async commit(): Promise<string | undefined> {
try {
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
console.info(`Using shadow git at: ${gitPath}`)
await this.gitOperations.addCheckpointFiles(git)
// Disable nested git repos before any operations
await this.gitOperations.renameNestedGitRepos(true)
const commitMessage = "checkpoint-" + this.cwdHash + "-" + this.taskId
try {
if (!this.isLegacyCheckpoint) {
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
}
await this.gitOperations.addCheckpointFiles(git, gitPath)
console.info(`Creating checkpoint commit with message: ${commitMessage}`)
const result = await git.commit(commitMessage, {
"--allow-empty": null,
"--no-verify": null,
})
const commitHash = result.commit || ""
console.warn(`Checkpoint commit created.`)
const commitMessage = this.isLegacyCheckpoint ? "checkpoint" : "checkpoint-" + this.cwdHash + "-" + this.taskId
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
return commitHash
console.info(
`Creating ${this.isLegacyCheckpoint ? "legacy" : "new"} checkpoint commit with message: ${commitMessage}`,
)
const result = await git.commit(commitMessage, {
"--allow-empty": null,
})
const commitHash = result.commit || ""
this.lastCheckpointHash = commitHash
console.warn(`Checkpoint commit created.`)
return commitHash
} finally {
// Always re-enable nested git repos
await this.gitOperations.renameNestedGitRepos(false)
}
} catch (error) {
console.error("Failed to create checkpoint:", {
taskId: this.taskId,
error,
isLegacyCheckpoint: this.isLegacyCheckpoint,
})
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
}
@@ -191,6 +233,7 @@ class CheckpointTracker {
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
* - Returns cached value if available
* - Reads git config if no cached value exists
* - Handles both legacy and new checkpoint structures
*
* Configuration read:
* - Uses simple-git to read core.worktree config
@@ -210,7 +253,7 @@ class CheckpointTracker {
return this.lastRetrievedShadowGitConfigWorkTree
}
try {
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
return this.lastRetrievedShadowGitConfigWorkTree
} catch (error) {
@@ -226,6 +269,7 @@ class CheckpointTracker {
*
* Dependencies:
* - Requires initialized shadow git (getShadowGitPath)
* - For new checkpoints, requires task branch setup
* - Must be called with a valid commit hash from this task's history
*
* @param commitHash - The hash of the checkpoint commit to reset to
@@ -233,20 +277,17 @@ class CheckpointTracker {
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Switch to task branch
* - Reset to target commit
*/
public async resetHead(commitHash: string): Promise<void> {
console.info(`Resetting to checkpoint: ${commitHash}`)
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
console.debug(`Using shadow git at: ${gitPath}`)
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
await git.reset(["--hard", commitHash]) // Hard reset to target commit
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "restored", durationMs)
}
/**
@@ -255,7 +296,6 @@ class CheckpointTracker {
* - the current working directory (including uncommitted changes).
*
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
* If you want truly untracked files to appear, `git add` them first.
*
* @param lhsHash - The commit to compare from (older commit)
* @param rhsHash - The commit to compare to (newer commit).
@@ -263,7 +303,7 @@ class CheckpointTracker {
* @returns Array of file changes with before/after content
*/
public async getDiffSet(
lhsHash: string,
lhsHash?: string,
rhsHash?: string,
): Promise<
Array<{
@@ -273,87 +313,144 @@ class CheckpointTracker {
after: string
}>
> {
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
if (!this.isLegacyCheckpoint) {
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
}
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git)
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
console.info(`Diff range: ${diffRange}`)
const diffSummary = await git.diffSummary([diffRange])
const result = []
for (const file of diffSummary.files) {
const filePath = file.file
const absolutePath = path.join(this.cwd, filePath)
let beforeContent = ""
try {
beforeContent = await git.show([`${lhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in older commit => remains empty
}
let afterContent = ""
if (rhsHash) {
try {
afterContent = await git.show([`${rhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in newer commit => remains empty
}
} else {
try {
afterContent = await fs.readFile(absolutePath, "utf8")
} catch (_) {
// file might be deleted => remains empty
}
}
result.push({
relativePath: filePath,
absolutePath,
before: beforeContent,
after: afterContent,
})
// If lhsHash is missing, use the initial commit of the repo
let baseHash = lhsHash
if (!baseHash) {
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
baseHash = rootCommit.trim()
console.debug(`Using root commit as base: ${baseHash}`)
}
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git, gitPath)
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
console.info(`Found ${diffSummary.files.length} changed files`)
// For each changed file, gather before/after content
const result = []
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
const files = diffSummary.files.map((f) => f.file)
const batchSize = 50
// Get list of files that exist in base commit
const existingFiles = await this.getExistingFiles(git, baseHash)
// Process files in batches
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize)
// Split batch into existing and new files
const existingBatch = batch.filter((file) => existingFiles.has(file))
const newBatch = batch.filter((file) => !existingFiles.has(file))
// Get before contents for existing files
let beforeContents: string[] = new Array(batch.length).fill("")
if (existingBatch.length > 0) {
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
const args = ["show", "--format="]
existingBatch.forEach((file) => {
args.push(`${baseHash}:${file}`)
})
const beforeResult = await git.raw(args)
const existingContents = beforeResult.split("\n\0\n")
// Map contents back to original batch positions
existingBatch.forEach((file, index) => {
const batchIndex = batch.indexOf(file)
if (batchIndex !== -1) {
beforeContents[batchIndex] = existingContents[index] || ""
}
})
}
// Get after contents
let afterContents: string[] = []
if (rhsHash) {
// Split after files into existing and new in target commit
const afterExistingFiles = await this.getExistingFiles(git, rhsHash)
const afterExistingBatch = batch.filter((file) => afterExistingFiles.has(file))
if (afterExistingBatch.length > 0) {
const args = ["show", "--format="]
afterExistingBatch.forEach((file) => {
args.push(`${rhsHash}:${file}`)
})
const afterResult = await git.raw(args)
const existingContents = afterResult.split("\n\0\n")
afterContents = new Array(batch.length).fill("")
afterExistingBatch.forEach((file, index) => {
const batchIndex = batch.indexOf(file)
if (batchIndex !== -1) {
afterContents[batchIndex] = existingContents[index] || ""
}
})
}
} else {
// Read from disk for working directory changes
afterContents = await Promise.all(
batch.map(async (filePath) => {
try {
return await fs.readFile(path.join(cwdPath, filePath), "utf8")
} catch (_) {
return ""
}
}),
)
}
// Add results for this batch
for (let j = 0; j < batch.length; j++) {
const filePath = batch[j]
const absolutePath = path.join(cwdPath, filePath)
result.push({
relativePath: filePath,
absolutePath,
before: beforeContents[j] || "",
after: afterContents[j] || "",
})
}
}
return result
}
/**
* Returns the number of files changed between two commits.
* Deletes all checkpoint data for a given task.
* Handles both legacy checkpoints and branch-per-task checkpoints.
*
* @param lhsHash - The commit to compare from (older commit)
* @param rhsHash - The commit to compare to (newer commit).
* If omitted, we compare to the working directory.
* @returns The number of files changed between the commits
* @param taskId - The ID of the task whose checkpoints should be deleted
* @param historyItem - The history item containing the shadow git config for this task
* @param globalStoragePath - the globalStorage path
* @throws Error if deletion fails
*/
public async getDiffCount(lhsHash: string, rhsHash?: string): Promise<number> {
const startTime = performance.now()
public static async deleteCheckpoints(taskId: string, historyItem: HistoryItem, globalStoragePath: string): Promise<void> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
await GitOperations.deleteTaskBranchStatic(taskId, historyItem, globalStoragePath)
}
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
console.info(`Getting diff count between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git)
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
const diffSummary = await git.diffSummary([diffRange])
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
return diffSummary.files.length
/**
* Helper function to get a set of files that exist in a given commit
*/
private async getExistingFiles(git: SimpleGit, commitHash: string): Promise<Set<string>> {
try {
const result = await git.raw(["ls-tree", "-r", "--name-only", commitHash])
const existingFiles = new Set<string>(result.split("\n"))
return existingFiles
} catch (error) {
console.error("Error getting existing files:", error)
return new Set()
}
}
}
+113 -4
View File
@@ -1,12 +1,44 @@
import { mkdir } from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import fs from "fs/promises"
import os from "os"
import { fileExistsAtPath } from "../../utils/fs"
/**
* Gets the path to the legacy shadow Git repository in globalStorage.
* Legacy checkpoints stored each task's checkpoints in a separate git repository
* under the tasks/{taskId}/checkpoints directory.
*
* Legacy path structure:
* globalStorage/
* tasks/
* {taskId}/
* checkpoints/
* .git/
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task
* @returns Promise<string> The absolute path to the legacy shadow git directory
* @throws Error if global storage path is invalid
*/
export async function getLegacyShadowGitPath(globalStoragePath: string, taskId: string): Promise<string> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
await fs.mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
console.log(`Legacy shadow git path: ${gitPath}`)
return gitPath
}
/**
* Gets the path to the shadow Git repository in globalStorage.
* For legacy checkpoints, delegates to getLegacyShadowGitPath().
* For new checkpoints, uses the consolidated branch-per-task structure.
*
* Checkpoints path structure:
* Branch-per-task path structure:
* globalStorage/
* checkpoints/
* {cwdHash}/
@@ -15,15 +47,25 @@ import os from "os"
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task
* @param cwdHash - Hash of the working directory path
* @param isLegacyCheckpoint - Whether this is a legacy checkpoint
* @returns Promise<string> The absolute path to the shadow git directory
* @throws Error if global storage path is invalid
*/
export async function getShadowGitPath(globalStoragePath: string, taskId: string, cwdHash: string): Promise<string> {
export async function getShadowGitPath(
globalStoragePath: string,
taskId: string,
cwdHash: string,
isLegacyCheckpoint: boolean,
): Promise<string> {
if (isLegacyCheckpoint) {
return getLegacyShadowGitPath(globalStoragePath, taskId)
}
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
await mkdir(checkpointsDir, { recursive: true })
await fs.mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
@@ -84,3 +126,70 @@ export function hashWorkingDir(workingDir: string): string {
const numericHash = bigHash.toString().slice(0, 13)
return numericHash
}
/**
* Detects if a task uses the legacy checkpoint structure.
* Legacy checkpoints stored each task's checkpoints in a separate git repository
* under the tasks/{taskId}/checkpoints directory. New checkpoints use a single
* repository with branches per task.
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task to check
* @returns Promise<boolean> True if task uses legacy checkpoint structure, false otherwise
*
* Legacy path structure:
* globalStorage/
* tasks/
* {taskId}/
* checkpoints/
* .git/
*
* Branch-per-task structure:
* globalStorage/
* checkpoints/
* {cwdHash}/
* .git/
*/
export async function detectLegacyCheckpoint(globalStoragePath: string | undefined, taskId: string): Promise<boolean> {
if (!globalStoragePath) {
return false
}
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
const isLegacy = await fileExistsAtPath(legacyGitPath)
console.info(`Legacy checkpoint detection result: ${isLegacy}`)
return isLegacy
}
/**
* Deletes all checkpoint data across all tasks.
* This is a destructive operation that removes all checkpoint history.
* Handles both legacy checkpoints (under tasks/{taskId}/checkpoints/.git/)
* and branch-per-task checkpoints (under checkpoints/{workspaceHash}/.git/).
*
* @param globalStoragePath - The VS Code global storage path
* @throws Error if deletion fails or if global storage path is invalid
*/
export async function deleteAllCheckpoints(globalStoragePath: string): Promise<void> {
if (!globalStoragePath) {
throw new Error("Global storage path is invalid")
}
// Delete legacy checkpoints
const tasksDir = path.join(globalStoragePath, "tasks")
if (await fileExistsAtPath(tasksDir)) {
const taskDirs = await fs.readdir(tasksDir)
for (const taskId of taskDirs) {
const checkpointsDir = path.join(tasksDir, taskId, "checkpoints")
if (await fileExistsAtPath(checkpointsDir)) {
await fs.rm(checkpointsDir, { recursive: true, force: true })
}
}
}
// Delete branch-per-task checkpoints
const checkpointsDir = path.join(globalStoragePath, "checkpoints")
if (await fileExistsAtPath(checkpointsDir)) {
await fs.rm(checkpointsDir, { recursive: true, force: true })
}
}
+23 -4
View File
@@ -47,7 +47,10 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
}
}
export function formatContentBlockToMarkdown(block: Anthropic.ContentBlockParam): string {
export function formatContentBlockToMarkdown(
block: Anthropic.ContentBlockParam,
// messages: Anthropic.MessageParam[]
): string {
switch (block.type) {
case "text":
return block.text
@@ -66,16 +69,32 @@ export function formatContentBlockToMarkdown(block: Anthropic.ContentBlockParam)
}
return `[Tool Use: ${block.name}]\n${input}`
case "tool_result":
// For now we're not doing tool name lookup since we don't use tools anymore
// const toolName = findToolName(block.tool_use_id, messages)
const toolName = "Tool"
if (typeof block.content === "string") {
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content}`
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content}`
} else if (Array.isArray(block.content)) {
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content
.map((contentBlock) => formatContentBlockToMarkdown(contentBlock))
.join("\n")}`
} else {
return `[Tool${block.is_error ? " (Error)" : ""}]`
return `[${toolName}${block.is_error ? " (Error)" : ""}]`
}
default:
return "[Unexpected content type]"
}
}
export function findToolName(toolCallId: string, messages: Anthropic.MessageParam[]): string {
for (const message of messages) {
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "tool_use" && block.id === toolCallId) {
return block.name
}
}
}
}
return "Unknown Tool"
}
-6
View File
@@ -4,7 +4,6 @@ import pdf from "pdf-parse/lib/pdf-parse"
import mammoth from "mammoth"
import fs from "fs/promises"
import { isBinaryFile } from "isbinaryfile"
import { getFileSizeInKB } from "../../utils/fs"
export async function extractTextFromFile(filePath: string): Promise<string> {
try {
@@ -23,11 +22,6 @@ export async function extractTextFromFile(filePath: string): Promise<string> {
default:
const isBinary = await isBinaryFile(filePath).catch(() => false)
if (!isBinary) {
// If file is over 300KB, throw an error
const fileSizeInKB = await getFileSizeInKB(filePath)
if (fileSizeInKB > 300) {
throw new Error(`File is too large to read into context.`)
}
return await fs.readFile(filePath, "utf8")
} else {
throw new Error(`Cannot read text for file type: ${fileExtension}`)
@@ -1,398 +0,0 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import "should"
import * as sinon from "sinon"
import { TerminalProcess } from "./TerminalProcess"
import * as vscode from "vscode"
import { TerminalRegistry } from "./TerminalRegistry"
import { EventEmitter } from "events"
declare module "vscode" {
// https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L7442
interface Terminal {
shellIntegration?: {
cwd?: vscode.Uri
executeCommand?: (command: string) => {
read: () => AsyncIterable<string>
}
}
}
}
// Create a mock stream for simulating terminal output - this is only used for tests
// that need controlled output which can't be guaranteed with real terminals
function createMockStream(lines: string[] = ["test-command", "line1", "line2", "line3"]) {
return {
async *[Symbol.asyncIterator]() {
for (const line of lines) {
yield line + "\n"
}
},
}
}
describe("TerminalProcess (Integration Tests)", () => {
let process: TerminalProcess
let sandbox: sinon.SinonSandbox
let createdTerminals: vscode.Terminal[] = []
beforeEach(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true })
process = new TerminalProcess()
})
afterEach(() => {
// Restore sandbox, which restores timers and all Sinon fakes
sandbox.restore()
// Remove any event listeners left on the TerminalProcess
process.removeAllListeners()
// Dispose all terminals created during the test
createdTerminals.forEach((t) => t.dispose())
createdTerminals = []
})
describe("Real terminal tests", () => {
// This test works with or without shell integration
it("should create and run a command in a real terminal", async () => {
// Create a real VS Code terminal for testing
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify behavior
const emitSpy = sandbox.spy(process, "emit")
// Run a simple command
await process.run(terminal, "echo test")
// Verify that the continue event was emitted
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
})
it("should execute and capture events from a simple command", async () => {
// Create a real VS Code terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify line events
const emitSpy = sandbox.spy(process, "emit")
// Run a command that produces predictable output
await process.run(terminal, "echo 'Line 1' && echo 'Line 2'")
// Check that the events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
})
it("should execute a command that lists files", async () => {
// Create a real VS Code terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify behavior
const emitSpy = sandbox.spy(process, "emit")
// Run a command that lists files
await process.run(terminal, "ls -la")
// Verify that the continue event was emitted
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
})
it("should handle a longer running command", async () => {
// Create a real terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify behavior
const emitSpy = sandbox.spy(process, "emit")
// Un-fake timers temporarily for this test since we need real timing
sandbox.clock.restore()
// Run a command that sleeps for a short period
await process.run(terminal, "sleep 0.5 && echo 'Done sleeping'")
// Verify that the continue and completed events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
// Restore fake timers for other tests
sandbox.useFakeTimers()
})
it("should execute a command with arguments", async () => {
// Create a real VS Code terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify line events
const emitSpy = sandbox.spy(process, "emit")
// Run a command that produces predictable output
await process.run(terminal, "echo 'Line 1' 'Line 2'")
// Check that the events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
})
it("should execute a command with quotes", async () => {
// Create a real VS Code terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify line events
const emitSpy = sandbox.spy(process, "emit")
// Run a command that produces predictable output
await process.run(terminal, "echo \"Line 1\" && echo 'Line 2'")
// Check that the events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
})
})
// Test that specifically checks for no shell integration
it("should handle terminals without shell integration", async () => {
// Create a real terminal without explicitly providing shell integration
const terminal = vscode.window.createTerminal({ name: "Test Terminal" })
createdTerminals.push(terminal)
// Stub the shellIntegration getter to return undefined for this test
sandbox.stub(terminal, "shellIntegration").get(() => undefined)
// Stub the sendText method to verify it's called
const sendTextStub = sandbox.stub(terminal, "sendText")
// Spy on the emit function to verify events
const emitSpy = sandbox.spy(process, "emit")
// Run the command
await process.run(terminal, "test-command")
// Check that the correct methods were called and events emitted
sendTextStub.calledWith("test-command", true).should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
// This event should be emitted for terminals without shell integration
;(emitSpy as sinon.SinonSpy).calledWith("no_shell_integration").should.be.true()
})
// The following tests require shell integration and controlled terminal output
describe("Shell integration tests", () => {
// We'll mock the terminal run process and TerminalProcess for these tests
it("should emit completed and continue events when command finishes", async function () {
// Create a terminal to ensure proper interface, but we'll use mocking under the hood
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Create a mock implementation of executeCommand
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["echo test", "test output"]),
})
// Create a fake shell integration object
const mockShellIntegration = {
executeCommand: mockExecuteCommand,
}
// Stub terminal.shellIntegration to return our mock
sandbox.stub(terminal, "shellIntegration").get(() => mockShellIntegration)
// Spy on emit to verify behavior
const emitSpy = sandbox.spy(process, "emit")
// Run the command
await process.run(terminal, "echo test")
// Verify the executeCommand was called with the right command
mockExecuteCommand.calledWith("echo test").should.be.true()
// Check that the events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
})
})
// Tests with controlled output
describe("Controlled output tests", () => {
it("should emit line events for each line of output", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration with controlled output
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["test-command", "line1", "line2", "line3"]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
const emitSpy = sandbox.spy(process, "emit")
await process.run(terminal, "test-command")
// Check that line events were emitted for each line
;(emitSpy as sinon.SinonSpy).calledWith("line", "line1").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "line2").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "line3").should.be.true()
})
it("should properly handle process hot state (e.g. compiling)", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["compiling..."]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
// Spy on global setTimeout
const setTimeoutSpy = sandbox.spy(global, "setTimeout")
await process.run(terminal, "build command")
// Move time forward enough to schedule
sandbox.clock.tick(100)
// Expect a 15-second (>= 10000ms) hot timeout, since it saw "compiling"
const foundCompilingTimeout = setTimeoutSpy.args.filter((args) => args[1] && args[1] >= 10000)
foundCompilingTimeout.length.should.be.greaterThan(0)
})
it("should handle standard commands with normal hot timeout", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["some normal output"]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
const setTimeoutSpy = sandbox.spy(global, "setTimeout")
await process.run(terminal, "standard command")
sandbox.clock.tick(100)
// Expect a short hot timeout (<= 5000)
const foundNormalTimeout = setTimeoutSpy.args.filter((args) => args[1] && args[1] <= 5000)
foundNormalTimeout.length.should.be.greaterThan(0)
// Also check that "completed" eventually emits
const emitSpy = sandbox.spy(process, "emit")
await process.run(terminal, "another command")
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
})
it("should correctly filter command echoes based on current implementation", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration
const mockExecuteCommand = sandbox.stub().returns({
read: () =>
createMockStream([
"test-command", // This should be filtered (command contains this exactly)
"test command", // This should NOT be filtered (doesn't match exactly)
"other output",
]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
const emitSpy = sandbox.spy(process, "emit")
await process.run(terminal, "test-command")
// Check that "test-command" was filtered out but "test command" was not
;(emitSpy as sinon.SinonSpy).calledWith("line", "test command").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "other output").should.be.true()
// This should never be called because it should be filtered
;(emitSpy as sinon.SinonSpy).calledWith("line", "test-command").should.be.false()
})
it("should handle npm run commands", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["npm run build", "> project@1.0.0 build", "> tsc", "files built successfully"]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
const emitSpy = sandbox.spy(process, "emit")
await process.run(terminal, "npm run build")
// The "npm run build" line should be filtered, but the rest should be emitted
;(emitSpy as sinon.SinonSpy).calledWith("line", "> project@1.0.0 build").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "> tsc").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "files built successfully").should.be.true()
})
})
// The following tests are shared with the unit tests to ensure consistent behavior
it("should emit line for remaining buffer when emitRemainingBufferIfListening is called", () => {
// Access private properties via type assertion
const processAny = process as any
processAny.buffer = "test buffer content"
processAny.isListening = true
const emitSpy = sandbox.spy(process, "emit")
processAny.emitRemainingBufferIfListening()
;(emitSpy as sinon.SinonSpy).calledWith("line", "test buffer content").should.be.true()
processAny.buffer.should.equal("")
})
it("should remove prompt characters from the last line of output", () => {
const processAny = process as any
processAny.removeLastLineArtifacts("line 1\nline 2 %").should.equal("line 1\nline 2")
processAny.removeLastLineArtifacts("line 1\nline 2 $").should.equal("line 1\nline 2")
processAny.removeLastLineArtifacts("line 1\nline 2 #").should.equal("line 1\nline 2")
processAny.removeLastLineArtifacts("line 1\nline 2 >").should.equal("line 1\nline 2")
})
it("should process buffer and emit lines when newline characters are found", () => {
const processAny = process as any
const emitSpy = sandbox.spy(process, "emit")
processAny.emitIfEol("line 1\nline 2\nline 3")
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 1").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 2").should.be.true()
processAny.buffer.should.equal("line 3")
processAny.emitIfEol(" continued\n")
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 3 continued").should.be.true()
processAny.buffer.should.equal("")
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from "events"
import { stripAnsi } from "./ansiUtils"
import stripAnsi from "strip-ansi"
import * as vscode from "vscode"
export interface TerminalProcessEvents {
-14
View File
@@ -1,14 +0,0 @@
export function ansiRegex({ onlyFirst = false } = {}) {
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"
const pattern = [
`[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join("|")
return new RegExp(pattern, onlyFirst ? undefined : "g")
}
export function stripAnsi(string: string): string {
return string.replace(ansiRegex(), "")
}
+99
View File
@@ -0,0 +1,99 @@
import { initializeApp } from "firebase/app"
import { Auth, User, getAuth, onAuthStateChanged, signInWithCustomToken, signOut } from "firebase/auth"
import * as vscode from "vscode"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { firebaseConfig } from "./config"
export interface UserInfo {
displayName: string | null
email: string | null
photoURL: string | null
}
export class FirebaseAuthManager {
private providerRef: WeakRef<ClineProvider>
private auth: Auth
private disposables: vscode.Disposable[] = []
constructor(provider: ClineProvider) {
console.log("Initializing FirebaseAuthManager", { provider })
this.providerRef = new WeakRef(provider)
const app = initializeApp(firebaseConfig)
this.auth = getAuth(app)
console.log("Firebase app initialized", { appConfig: firebaseConfig })
// Auth state listener
onAuthStateChanged(this.auth, this.handleAuthStateChange.bind(this))
console.log("Auth state change listener added")
// Try to restore session
this.restoreSession()
}
private async restoreSession() {
console.log("Attempting to restore session")
const provider = this.providerRef.deref()
if (!provider) {
console.log("Provider reference lost during session restore")
return
}
const storedToken = await provider.getSecret("authToken")
if (storedToken) {
console.log("Found stored auth token, attempting to restore session")
try {
await this.signInWithCustomToken(storedToken)
console.log("Session restored successfully")
} catch (error) {
console.error("Failed to restore session, clearing token:", error)
await provider.setAuthToken(undefined)
await provider.setUserInfo(undefined)
}
} else {
console.log("No stored auth token found")
}
}
private async handleAuthStateChange(user: User | null) {
console.log("Auth state changed", { user })
const provider = this.providerRef.deref()
if (!provider) {
console.log("Provider reference lost")
return
}
if (user) {
console.log("User signed in", { userId: user.uid })
const idToken = await user.getIdToken()
await provider.setAuthToken(idToken)
// Store public user info in state
await provider.setUserInfo({
displayName: user.displayName,
email: user.email,
photoURL: user.photoURL,
})
console.log("User info set in provider", { user })
} else {
console.log("User signed out")
await provider.setAuthToken(undefined)
await provider.setUserInfo(undefined)
}
await provider.postStateToWebview()
console.log("Webview state updated")
}
async signInWithCustomToken(token: string) {
console.log("Signing in with custom token", { token })
await signInWithCustomToken(this.auth, token)
}
async signOut() {
console.log("Signing out")
await signOut(this.auth)
}
dispose() {
this.disposables.forEach((d) => d.dispose())
console.log("Disposables disposed", { count: this.disposables.length })
}
}
+1 -2
View File
@@ -36,7 +36,7 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
".*", // '!**/.*' excludes hidden directories, while '!**/.*/**' excludes only their contents. This way we are at least aware of the existence of hidden directories.
].map((dir) => `**/${dir}/**`)
const options: Options = {
const options = {
cwd: dirPath,
dot: true, // do not ignore hidden files/directories
absolute: true,
@@ -44,7 +44,6 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
gitignore: recursive, // globby ignores any files that are gitignored
ignore: recursive ? dirsToIgnore : undefined, // just in case there is no gitignore, we ignore sensible defaults
onlyFiles: false, // true by default, false means it will list directories on their own too
suppressErrors: true,
}
// * globs all files in one dir, ** globs files in nested directories
+30 -56
View File
@@ -14,9 +14,8 @@ import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
import {
DEFAULT_MCP_TIMEOUT_SECONDS,
McpMode,
McpResource,
McpResourceResponse,
@@ -24,12 +23,10 @@ import {
McpServer,
McpTool,
McpToolCallResponse,
MIN_MCP_TIMEOUT_SECONDS,
} from "../../shared/mcp"
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { secondsToMs } from "../../utils/time"
import { GlobalFileNames } from "../../global-constants"
export type McpConnection = {
server: McpServer
client: Client
@@ -38,13 +35,13 @@ export type McpConnection = {
const AutoApproveSchema = z.array(z.string()).default([])
// StdioServerParameters
const StdioConfigSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
autoApprove: AutoApproveSchema.optional(),
disabled: z.boolean().optional(),
timeout: z.number().min(MIN_MCP_TIMEOUT_SECONDS).optional().default(DEFAULT_MCP_TIMEOUT_SECONDS),
})
const McpSettingsSchema = z.object({
@@ -245,6 +242,28 @@ export class McpHub {
}
transport.start = async () => {} // No-op now, .connect() won't fail
// // Set up notification handlers
// client.setNotificationHandler(
// // @ts-ignore-next-line
// { method: "notifications/tools/list_changed" },
// async () => {
// console.log(`Tools changed for server: ${name}`)
// connection.server.tools = await this.fetchTools(name)
// await this.notifyWebviewOfServerChanges()
// },
// )
// client.setNotificationHandler(
// // @ts-ignore-next-line
// { method: "notifications/resources/list_changed" },
// async () => {
// console.log(`Resources changed for server: ${name}`)
// connection.server.resources = await this.fetchResources(name)
// connection.server.resourceTemplates = await this.fetchResourceTemplates(name)
// await this.notifyWebviewOfServerChanges()
// },
// )
// Connect
await client.connect(transport)
connection.server.status = "connected"
@@ -324,6 +343,10 @@ export class McpHub {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
try {
// connection.client.removeNotificationHandler("notifications/tools/list_changed")
// connection.client.removeNotificationHandler("notifications/resources/list_changed")
// connection.client.removeNotificationHandler("notifications/stderr")
// connection.client.removeNotificationHandler("notifications/stderr")
await connection.transport.close()
await connection.client.close()
} catch (error) {
@@ -540,7 +563,6 @@ export class McpHub {
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled`)
}
return await connection.client.request(
{
method: "resources/read",
@@ -564,16 +586,6 @@ export class McpHub {
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
}
let timeout = secondsToMs(DEFAULT_MCP_TIMEOUT_SECONDS) // sdk expects ms
try {
const config = JSON.parse(connection.server.config)
const parsedConfig = StdioConfigSchema.parse(config)
timeout = secondsToMs(parsedConfig.timeout)
} catch (error) {
console.error(`Failed to parse timeout configuration for server ${serverName}: ${error}`)
}
return await connection.client.request(
{
method: "tools/call",
@@ -583,9 +595,6 @@ export class McpHub {
},
},
CallToolResultSchema,
{
timeout,
},
)
}
@@ -611,6 +620,7 @@ export class McpHub {
autoApprove.splice(toolIndex, 1)
}
// Write updated config back to file
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
// Update the tools list to reflect the change
@@ -653,42 +663,6 @@ export class McpHub {
}
}
public async updateServerTimeout(serverName: string, timeout: number): Promise<void> {
try {
// Validate timeout against schema
const setConfigResult = StdioConfigSchema.shape.timeout.safeParse(timeout)
if (!setConfigResult.success) {
throw new Error(`Invalid timeout value: ${timeout}. Must be at minimum ${MIN_MCP_TIMEOUT_SECONDS} seconds.`)
}
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
if (!config.mcpServers?.[serverName]) {
throw new Error(`Server "${serverName}" not found in settings`)
}
config.mcpServers[serverName] = {
...config.mcpServers[serverName],
timeout,
}
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
await this.updateServerConnections(config.mcpServers)
} catch (error) {
console.error("Failed to update server timeout:", error)
if (error instanceof Error) {
console.error("Error details:", error.message, error.stack)
}
vscode.window.showErrorMessage(
`Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`,
)
throw error
}
}
async dispose(): Promise<void> {
this.removeAllFileWatchers()
for (const connection of this.connections) {
+7 -374
View File
@@ -1,78 +1,12 @@
import { PostHog } from "posthog-node"
import * as vscode from "vscode"
import { version as extensionVersion } from "../../../package.json"
/**
* PostHogClient handles telemetry event tracking for the Cline extension
* Uses PostHog analytics to track user interactions and system events
* Respects user privacy settings and VSCode's global telemetry configuration
*/
class PostHogClient {
// Event constants for tracking user interactions and system events
private static readonly EVENTS = {
// Task-related events for tracking conversation and execution flow
TASK: {
// Tracks when a new task/conversation is started
CREATED: "task.created",
// Tracks when a task is reopened
RESTARTED: "task.restarted",
// Tracks when a task is finished, with acceptance or rejection status
COMPLETED: "task.completed",
// Tracks when a message is sent in a conversation
CONVERSATION_TURN: "task.conversation_turn",
// Tracks token consumption for cost and usage analysis
TOKEN_USAGE: "task.tokens",
// Tracks switches between plan and act modes
MODE_SWITCH: "task.mode",
// Tracks usage of the git-based checkpoint system (shadow_git_initialized, commit_created, branch_created, branch_deleted_active, branch_deleted_inactive, restored)
CHECKPOINT_USED: "task.checkpoint_used",
// Tracks when tools (like file operations, commands) are used
TOOL_USED: "task.tool_used",
// Tracks when a historical task is loaded from storage
HISTORICAL_LOADED: "task.historical_loaded",
// Tracks when the retry button is clicked for failed operations
RETRY_CLICKED: "task.retry_clicked",
},
// UI interaction events for tracking user engagement
UI: {
// Tracks when user switches between API providers
PROVIDER_SWITCH: "ui.provider_switch",
// Tracks when images are attached to a conversation
IMAGE_ATTACHED: "ui.image_attached",
// Tracks general button click interactions
BUTTON_CLICK: "ui.button_click",
// Tracks when the marketplace view is opened
MARKETPLACE_OPENED: "ui.marketplace_opened",
// Tracks when settings panel is opened
SETTINGS_OPENED: "ui.settings_opened",
// Tracks when task history view is opened
HISTORY_OPENED: "ui.history_opened",
// Tracks when a task is removed from history
TASK_POPPED: "ui.task_popped",
// Tracks when a different model is selected
MODEL_SELECTED: "ui.model_selected",
// Tracks when planning mode is toggled on
PLAN_MODE_TOGGLED: "ui.plan_mode_toggled",
// Tracks when action mode is toggled on
ACT_MODE_TOGGLED: "ui.act_mode_toggled",
},
}
/** Singleton instance of the PostHogClient */
private static instance: PostHogClient
/** PostHog client instance for sending analytics events */
private client: PostHog
/** Unique identifier for the current VSCode instance */
private distinctId: string = vscode.env.machineId
/** Whether telemetry is currently enabled based on user and VSCode settings */
private telemetryEnabled: boolean = false
/** Current version of the extension */
private readonly version: string = extensionVersion
/**
* Private constructor to enforce singleton pattern
* Initializes PostHog client with configuration
*/
private constructor() {
this.client = new PostHog("phc_qfOAGxZw2TL5O8p9KYd9ak3bPBFzfjC8fy5L6jNWY7K", {
host: "https://us.i.posthog.com",
@@ -80,11 +14,6 @@ class PostHogClient {
})
}
/**
* Updates the telemetry state based on user preferences and VSCode settings
* Only enables telemetry if both VSCode global telemetry is enabled and user has opted in
* @param didUserOptIn Whether the user has explicitly opted into telemetry
*/
public updateTelemetryState(didUserOptIn: boolean): void {
this.telemetryEnabled = false
@@ -97,18 +26,17 @@ class PostHogClient {
this.telemetryEnabled = didUserOptIn
}
// Update PostHog client state based on telemetry preference
// Update PostHog client state based on telemetry preference and use machineId to tie it to the webview
if (this.telemetryEnabled) {
this.client.optIn()
this.client.identify({ distinctId: this.distinctId })
// console.log("Telemetry enabled")
} else {
this.client.optOut()
// console.log("Telemetry disabled")
}
}
/**
* Gets or creates the singleton instance of PostHogClient
* @returns The PostHogClient instance
*/
public static getInstance(): PostHogClient {
if (!PostHogClient.instance) {
PostHogClient.instance = new PostHogClient()
@@ -116,310 +44,14 @@ class PostHogClient {
return PostHogClient.instance
}
/**
* Captures a telemetry event if telemetry is enabled
* @param event The event to capture with its properties
*/
public capture(event: { event: string; properties?: any }): void {
// Only send events if telemetry is enabled
if (this.telemetryEnabled) {
// Include extension version in all event properties
const propertiesWithVersion = {
...event.properties,
extension_version: this.version,
}
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: propertiesWithVersion })
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: event.properties })
// console.log("Captured event", { distinctId: this.distinctId, event: event.event, properties: event.properties })
}
}
// Task events
/**
* Records when a new task/conversation is started
* @param taskId Unique identifier for the new task
*/
public captureTaskCreated(taskId: string, apiProvider?: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.CREATED,
properties: { taskId, apiProvider },
})
}
/**
* Records when a task/conversation is restarted
* @param taskId Unique identifier for the new task
*/
public captureTaskRestarted(taskId: string, apiProvider?: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.RESTARTED,
properties: { taskId, apiProvider },
})
}
/**
* Records when cline calls the task completion_result tool signifying that cline is done with the task
* @param taskId Unique identifier for the task
*/
public captureTaskCompleted(taskId: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.COMPLETED,
properties: { taskId },
})
}
/**
* Captures that a message was sent, and includes the API provider and model used
* @param taskId Unique identifier for the task
* @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.
*/
public captureConversationTurnEvent(
taskId: string,
provider: string = "unknown",
model: string = "unknown",
source: "user" | "assistant",
) {
// Ensure required parameters are provided
if (!taskId || !provider || !model || !source) {
console.warn("TelemetryService: Missing required parameters for message capture")
return
}
const properties: Record<string, any> = {
taskId,
provider,
model,
source,
timestamp: new Date().toISOString(), // Add timestamp for message sequencing
}
this.capture({
event: PostHogClient.EVENTS.TASK.CONVERSATION_TURN,
properties,
})
}
/**
* TODO
* Records token usage metrics for cost tracking and usage analysis
* @param taskId Unique identifier for the task
* @param tokensIn Number of input tokens consumed
* @param tokensOut Number of output tokens generated
* @param model The model used for token calculation
*/
public captureTokenUsage(taskId: string, tokensIn: number, tokensOut: number, model: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.TOKEN_USAGE,
properties: {
taskId,
tokensIn,
tokensOut,
model,
},
})
}
/**
* Records when a task switches between plan and act modes
* @param taskId Unique identifier for the task
* @param mode The mode being switched to (plan or act)
*/
public captureModeSwitch(taskId: string, mode: "plan" | "act") {
this.capture({
event: PostHogClient.EVENTS.TASK.MODE_SWITCH,
properties: {
taskId,
mode,
},
})
}
// Tool events
/**
* Records when a tool is used during task execution
* @param taskId Unique identifier for the task
* @param tool Name of the tool being used
* @param autoApproved Whether the tool was auto-approved based on settings
* @param success Whether the tool execution was successful
*/
public captureToolUsage(taskId: string, tool: string, autoApproved: boolean, success: boolean) {
this.capture({
event: PostHogClient.EVENTS.TASK.TOOL_USED,
properties: {
taskId,
tool,
autoApproved,
success,
},
})
}
/**
* Records interactions with the git-based checkpoint system
* @param taskId Unique identifier for the task
* @param action The type of checkpoint action
* @param durationMs Optional duration of the operation in milliseconds
*/
public captureCheckpointUsage(
taskId: string,
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated",
durationMs?: number,
) {
this.capture({
event: PostHogClient.EVENTS.TASK.CHECKPOINT_USED,
properties: {
taskId,
action,
durationMs,
},
})
}
// UI events
/**
* Records when the user switches between different API providers
* @param from Previous provider name
* @param to New provider name
* @param location Where the switch occurred (settings panel or bottom bar)
* @param taskId Optional task identifier if switch occurred during a task
*/
public captureProviderSwitch(from: string, to: string, location: "settings" | "bottom", taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.PROVIDER_SWITCH,
properties: {
from,
to,
location,
taskId,
},
})
}
/**
* Records when images are attached to a conversation
* @param taskId Unique identifier for the task
* @param imageCount Number of images attached
*/
public captureImageAttached(taskId: string, imageCount: number) {
this.capture({
event: PostHogClient.EVENTS.UI.IMAGE_ATTACHED,
properties: {
taskId,
imageCount,
},
})
}
/**
* Records general button click interactions in the UI
* @param button Identifier for the button that was clicked
* @param taskId Optional task identifier if click occurred during a task
*/
public captureButtonClick(button: string, taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.BUTTON_CLICK,
properties: {
button,
taskId,
},
})
}
/**
* Records when the marketplace view is opened
* @param taskId Optional task identifier if marketplace was opened during a task
*/
public captureMarketplaceOpened(taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.MARKETPLACE_OPENED,
properties: {
taskId,
},
})
}
/**
* Records when the settings panel is opened
* @param taskId Optional task identifier if settings were opened during a task
*/
public captureSettingsOpened(taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.SETTINGS_OPENED,
properties: {
taskId,
},
})
}
/**
* Records when the task history view is opened
* @param taskId Optional task identifier if history was opened during a task
*/
public captureHistoryOpened(taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.HISTORY_OPENED,
properties: {
taskId,
},
})
}
/**
* Records when a task is removed from the task history
* @param taskId Unique identifier for the task being removed
*/
public captureTaskPopped(taskId: string) {
this.capture({
event: PostHogClient.EVENTS.UI.TASK_POPPED,
properties: {
taskId,
},
})
}
/**
* Records when a different model is selected for use
* @param model Name of the selected model
* @param provider Provider of the selected model
* @param taskId Optional task identifier if model was selected during a task
*/
public captureModelSelected(model: string, provider: string, taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.MODEL_SELECTED,
properties: {
model,
provider,
taskId,
},
})
}
/**
* Records when a historical task is loaded from storage
* @param taskId Unique identifier for the historical task
*/
public captureHistoricalTaskLoaded(taskId: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.HISTORICAL_LOADED,
properties: {
taskId,
},
})
}
/**
* Records when the retry button is clicked for failed operations
* @param taskId Unique identifier for the task being retried
*/
public captureRetryClicked(taskId: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.RETRY_CLICKED,
properties: {
taskId,
},
})
}
public isTelemetryEnabled(): boolean {
return this.telemetryEnabled
}
@@ -429,4 +61,5 @@ class PostHogClient {
}
}
// Export a single instance
export const telemetryService = PostHogClient.getInstance()
-2
View File
@@ -84,8 +84,6 @@ function separateFiles(allFiles: string[]): {
"java",
"php",
"swift",
// Kotlin
"kt",
].map((e) => `.${e}`)
const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file))).slice(0, 50) // 50 files max
const remainingFiles = allFiles.filter((file) => !filesToParse.includes(file))
@@ -13,7 +13,6 @@ import {
javaQuery,
phpQuery,
swiftQuery,
kotlinQuery,
} from "./queries"
export interface LanguageParser {
@@ -121,10 +120,6 @@ export async function loadRequiredLanguageParsers(filesToParse: string[]): Promi
language = await loadLanguage("swift")
query = language.query(swiftQuery)
break
case "kt":
language = await loadLanguage("kotlin")
query = language.query(kotlinQuery)
break
default:
throw new Error(`Unsupported language: ${ext}`)
}
@@ -10,4 +10,3 @@ export { default as cQuery } from "./c"
export { default as csharpQuery } from "./c-sharp"
export { default as goQuery } from "./go"
export { default as swiftQuery } from "./swift"
export { default as kotlinQuery } from "./kotlin"
@@ -1,22 +0,0 @@
export default `
(class_declaration
name: (simple_identifier) @name.definition.class) @definition.class
(function_declaration
name: (simple_identifier) @name.definition.function) @definition.function
(interface_declaration
name: (simple_identifier) @name.definition.interface) @definition.interface
(object_declaration
name: (simple_identifier) @name.definition.object) @definition.object
(property_declaration
name: (simple_identifier) @name.definition.property) @definition.property
(enum_declaration
name: (simple_identifier) @name.definition.enum) @definition.enum
(typealias_declaration
name: (simple_identifier) @name.definition.typealias) @definition.typealias
)`
+4
View File
@@ -0,0 +1,4 @@
export interface CheckpointSettings {
/** Whether checkpoints are enabled */
enableCheckpoints: boolean
}
+16 -31
View File
@@ -8,6 +8,7 @@ import { ChatSettings } from "./ChatSettings"
import { HistoryItem } from "./HistoryItem"
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
import { TelemetrySetting } from "./TelemetrySetting"
import { CheckpointSettings } from "./Checkpoints"
// webview will hold state
export interface ExtensionMessage {
@@ -27,14 +28,13 @@ export interface ExtensionMessage {
| "relinquishControl"
| "vsCodeLmModels"
| "requestVsCodeLmModels"
| "authCallback"
| "emailSubscribed"
| "mcpMarketplaceCatalog"
| "mcpDownloadDetails"
| "commitSearchResults"
| "openGraphData"
| "isImageUrlResult"
| "didUpdateSettings"
| "totalTasksSize"
| "setCheckpointSettings"
text?: string
action?:
| "chatButtonClicked"
@@ -44,7 +44,7 @@ export interface ExtensionMessage {
| "didBecomeVisible"
| "accountLoginClicked"
| "accountLogoutClicked"
invoke?: Invoke
invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
state?: ExtensionState
images?: string[]
ollamaModels?: string[]
@@ -55,10 +55,10 @@ export interface ExtensionMessage {
openRouterModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
customToken?: string
mcpMarketplaceCatalog?: McpMarketplaceCatalog
error?: string
mcpDownloadDetails?: McpDownloadResponse
checkpointSettings?: CheckpointSettings
commits?: GitCommit[]
openGraphData?: {
title?: string
@@ -70,37 +70,34 @@ export interface ExtensionMessage {
}
url?: string
isImage?: boolean
totalTasksSize?: number | null
}
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"
export const DEFAULT_PLATFORM = "unknown"
export interface ExtensionState {
version: string
apiConfiguration?: ApiConfiguration
customInstructions?: string
uriScheme?: string
currentTaskItem?: HistoryItem
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
chatSettings: ChatSettings
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem
customInstructions?: string
mcpMarketplaceEnabled?: boolean
planActSeparateModelsSetting: boolean
isLoggedIn: boolean
platform: Platform
shouldShowAnnouncement: boolean
taskHistory: HistoryItem[]
telemetrySetting: TelemetrySetting
uriScheme?: string
userInfo?: {
displayName: string | null
email: string | null
photoURL: string | null
}
version: string
mcpMarketplaceEnabled?: boolean
telemetrySetting: TelemetrySetting
vscMachineId: string
}
@@ -201,18 +198,6 @@ export interface ClineAskUseMcpServer {
uri?: string
}
export interface ClinePlanModeResponse {
response: string
options?: string[]
selected?: string
}
export interface ClineAskQuestion {
question: string
options?: string[]
selected?: string
}
export interface ClineApiReqInfo {
request?: string
tokensIn?: number
-5
View File
@@ -1,5 +0,0 @@
export interface UserInfo {
displayName: string | null
email: string | null
photoURL: string | null
}
+10 -16
View File
@@ -2,13 +2,13 @@ import { ApiConfiguration } from "./api"
import { AutoApprovalSettings } from "./AutoApprovalSettings"
import { BrowserSettings } from "./BrowserSettings"
import { ChatSettings } from "./ChatSettings"
import { UserInfo } from "./UserInfo"
import { ChatContent } from "./ChatContent"
import { TelemetrySetting } from "./TelemetrySetting"
import { CheckpointSettings } from "./Checkpoints"
export interface WebviewMessage {
type:
| "apiConfiguration"
| "customInstructions"
| "webviewDidLaunch"
| "newTask"
| "askResponse"
@@ -37,6 +37,10 @@ export interface WebviewMessage {
| "togglePlanActMode"
| "checkpointDiff"
| "checkpointRestore"
| "getCheckpointSettings"
| "updateCheckpointSettings"
| "openCheckpointsIgnore"
| "confirmDeleteAllCheckpoints"
| "taskCompletionViewChanges"
| "openExtensionSettings"
| "requestVsCodeLmModels"
@@ -45,8 +49,7 @@ export interface WebviewMessage {
| "getLatestState"
| "accountLoginClicked"
| "accountLogoutClicked"
| "authStateChanged"
| "authCallback"
| "subscribeEmail"
| "fetchMcpMarketplace"
| "downloadMcp"
| "silentlyRefreshMcpMarketplace"
@@ -58,11 +61,7 @@ export interface WebviewMessage {
| "updateMcpTimeout"
| "fetchOpenGraphData"
| "checkIsImageUrl"
| "invoke"
| "updateSettings"
| "clearAllTaskHistory"
| "optionsResponse"
| "requestTotalTasksSize"
| "updateThinkingBudgetTokens"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
@@ -73,23 +72,18 @@ export interface WebviewMessage {
number?: number
autoApprovalSettings?: AutoApprovalSettings
browserSettings?: BrowserSettings
checkpointSettings?: CheckpointSettings
chatSettings?: ChatSettings
chatContent?: ChatContent
mcpId?: string
timeout?: number
// For toggleToolAutoApprove
serverName?: string
toolName?: string
autoApprove?: boolean
// For auth
user?: UserInfo | null
customToken?: string
// For openInBrowser
url?: string
planActSeparateModelsSetting?: boolean
telemetrySetting?: TelemetrySetting
customInstructionsSetting?: string
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+19 -490
View File
@@ -14,16 +14,12 @@ export type ApiProvider =
| "qwen"
| "mistral"
| "vscode-lm"
| "cline"
| "litellm"
| "asksage"
| "xai"
| "sambanova"
export interface ApiHandlerOptions {
apiModelId?: string
apiKey?: string // anthropic
clineApiKey?: string
liteLlmBaseUrl?: string
liteLlmModelId?: string
liteLlmApiKey?: string
@@ -36,19 +32,16 @@ export interface ApiHandlerOptions {
awsSessionToken?: string
awsRegion?: string
awsUseCrossRegionInference?: boolean
awsBedrockUsePromptCache?: boolean
awsUseProfile?: boolean
awsProfile?: string
awsBedrockEndpoint?: string
vertexProjectId?: string
vertexRegion?: string
openAiBaseUrl?: string
openAiApiKey?: string
openAiModelId?: string
openAiModelInfo?: OpenAiCompatibleModelInfo
openAiModelInfo?: ModelInfo
ollamaModelId?: string
ollamaBaseUrl?: string
ollamaApiOptionsCtxNum?: string
lmStudioModelId?: string
lmStudioBaseUrl?: string
geminiApiKey?: string
@@ -64,11 +57,8 @@ export interface ApiHandlerOptions {
vsCodeLmModelSelector?: any
o3MiniReasoningEffort?: string
qwenApiLine?: string
asksageApiUrl?: string
asksageApiKey?: string
xaiApiKey?: string
thinkingBudgetTokens?: number
sambanovaApiKey?: string
}
export type ApiConfiguration = ApiHandlerOptions & {
@@ -90,14 +80,11 @@ export interface ModelInfo {
description?: string
}
export interface OpenAiCompatibleModelInfo extends ModelInfo {
temperature?: number
}
// Anthropic
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
export type AnthropicModelId = keyof typeof anthropicModels
export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219"
export const ANTHROPIC_THINKING_BUDGET_TOKENS_MIN = 1024
export const anthropicModels = {
"claude-3-7-sonnet-20250219": {
maxTokens: 8192,
@@ -110,6 +97,17 @@ export const anthropicModels = {
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"claude-3-7-sonnet-20250219:thinking": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"claude-3-5-sonnet-20241022": {
maxTokens: 8192,
contextWindow: 200_000,
@@ -174,21 +172,17 @@ export const bedrockModels = {
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"anthropic.claude-3-5-haiku-20241022-v1:0": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
supportsPromptCache: false,
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.0,
cacheReadsPrice: 0.08,
},
"anthropic.claude-3-5-sonnet-20240620-v1:0": {
maxTokens: 8192,
@@ -222,14 +216,6 @@ export const bedrockModels = {
inputPrice: 0.25,
outputPrice: 1.25,
},
"deepseek.r1-v1:0": {
maxTokens: 8_000,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.35,
outputPrice: 5.4,
},
} as const satisfies Record<string, ModelInfo>
// OpenRouter
@@ -314,104 +300,15 @@ export const vertexModels = {
cacheWritesPrice: 0.3,
cacheReadsPrice: 0.03,
},
"gemini-2.0-flash-001": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.1,
outputPrice: 0.4,
},
"gemini-2.0-flash-thinking-exp-1219": {
maxTokens: 8192,
contextWindow: 32_767,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-2.0-flash-exp": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-2.0-pro-exp-02-05": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-2.0-flash-thinking-exp-01-21": {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-exp-1206": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-1.5-flash-002": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-1.5-flash-exp-0827": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-1.5-flash-8b-exp-0827": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-1.5-pro-002": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-1.5-pro-exp-0827": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
} as const satisfies Record<string, ModelInfo>
export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
export const openAiModelInfoSaneDefaults: ModelInfo = {
maxTokens: -1,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
temperature: 0,
}
// Gemini
@@ -621,11 +518,9 @@ export const deepSeekModels = {
// Qwen
// https://bailian.console.aliyun.com/
export type MainlandQwenModelId = keyof typeof mainlandQwenModels
export type InternationalQwenModelId = keyof typeof internationalQwenModels
export const internationalQwenDefaultModelId: InternationalQwenModelId = "qwen-coder-plus-latest"
export const mainlandQwenDefaultModelId: MainlandQwenModelId = "qwen-coder-plus-latest"
export const internationalQwenModels = {
export type QwenModelId = keyof typeof qwenModels
export const qwenDefaultModelId: QwenModelId = "qwen-coder-plus-latest"
export const qwenModels = {
"qwen2.5-coder-32b-instruct": {
maxTokens: 8_192,
contextWindow: 131_072,
@@ -828,229 +723,6 @@ export const internationalQwenModels = {
},
} as const satisfies Record<string, ModelInfo>
export const mainlandQwenModels = {
"qwen2.5-coder-32b-instruct": {
maxTokens: 8_192,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.002,
outputPrice: 0.006,
cacheWritesPrice: 0.002,
cacheReadsPrice: 0.006,
},
"qwen2.5-coder-14b-instruct": {
maxTokens: 8_192,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.002,
outputPrice: 0.006,
cacheWritesPrice: 0.002,
cacheReadsPrice: 0.006,
},
"qwen2.5-coder-7b-instruct": {
maxTokens: 8_192,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.001,
outputPrice: 0.002,
cacheWritesPrice: 0.001,
cacheReadsPrice: 0.002,
},
"qwen2.5-coder-3b-instruct": {
maxTokens: 8_192,
contextWindow: 32_768,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.0,
outputPrice: 0.0,
cacheWritesPrice: 0.0,
cacheReadsPrice: 0.0,
},
"qwen2.5-coder-1.5b-instruct": {
maxTokens: 8_192,
contextWindow: 32_768,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.0,
outputPrice: 0.0,
cacheWritesPrice: 0.0,
cacheReadsPrice: 0.0,
},
"qwen2.5-coder-0.5b-instruct": {
maxTokens: 8_192,
contextWindow: 32_768,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.0,
outputPrice: 0.0,
cacheWritesPrice: 0.0,
cacheReadsPrice: 0.0,
},
"qwen-coder-plus-latest": {
maxTokens: 129_024,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 3.5,
outputPrice: 7,
cacheWritesPrice: 3.5,
cacheReadsPrice: 7,
},
"qwen-plus-latest": {
maxTokens: 129_024,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.8,
outputPrice: 2,
cacheWritesPrice: 0.8,
cacheReadsPrice: 0.2,
},
"qwen-turbo-latest": {
maxTokens: 1_000_000,
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.8,
outputPrice: 2,
cacheWritesPrice: 0.8,
cacheReadsPrice: 2,
},
"qwen-max-latest": {
maxTokens: 30_720,
contextWindow: 32_768,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 2.4,
outputPrice: 9.6,
cacheWritesPrice: 2.4,
cacheReadsPrice: 9.6,
},
"qwq-plus-latest": {
maxTokens: 8_192,
contextWindow: 131_071,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.0,
outputPrice: 0.0,
cacheWritesPrice: 0.0,
cacheReadsPrice: 0.0,
},
"qwq-plus": {
maxTokens: 8_192,
contextWindow: 131_071,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.0,
outputPrice: 0.0,
cacheWritesPrice: 0.0,
cacheReadsPrice: 0.0,
},
"qwen-coder-plus": {
maxTokens: 129_024,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 3.5,
outputPrice: 7,
cacheWritesPrice: 3.5,
cacheReadsPrice: 7,
},
"qwen-plus": {
maxTokens: 129_024,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.8,
outputPrice: 2,
cacheWritesPrice: 0.8,
cacheReadsPrice: 0.2,
},
"qwen-turbo": {
maxTokens: 1_000_000,
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.3,
outputPrice: 0.6,
cacheWritesPrice: 0.3,
cacheReadsPrice: 0.6,
},
"qwen-max": {
maxTokens: 30_720,
contextWindow: 32_768,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 2.4,
outputPrice: 9.6,
cacheWritesPrice: 2.4,
cacheReadsPrice: 9.6,
},
"deepseek-v3": {
maxTokens: 8_000,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0.28,
cacheWritesPrice: 0.14,
cacheReadsPrice: 0.014,
},
"deepseek-r1": {
maxTokens: 8_000,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 2.19,
cacheWritesPrice: 0.55,
cacheReadsPrice: 0.14,
},
"qwen-vl-max": {
maxTokens: 30_720,
contextWindow: 32_768,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3,
outputPrice: 9,
cacheWritesPrice: 3,
cacheReadsPrice: 9,
},
"qwen-vl-max-latest": {
maxTokens: 129_024,
contextWindow: 131_072,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3,
outputPrice: 9,
cacheWritesPrice: 3,
cacheReadsPrice: 9,
},
"qwen-vl-plus": {
maxTokens: 6_000,
contextWindow: 8_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 1.5,
outputPrice: 4.5,
cacheWritesPrice: 1.5,
cacheReadsPrice: 4.5,
},
"qwen-vl-plus-latest": {
maxTokens: 129_024,
contextWindow: 131_072,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 1.5,
outputPrice: 4.5,
cacheWritesPrice: 1.5,
cacheReadsPrice: 4.5,
},
} as const satisfies Record<string, ModelInfo>
// Mistral
// https://docs.mistral.ai/getting-started/models/models_overview/
export type MistralModelId = keyof typeof mistralModels
@@ -1088,14 +760,6 @@ export const mistralModels = {
inputPrice: 0.1,
outputPrice: 0.1,
},
"mistral-small-latest": {
maxTokens: 131_000,
contextWindow: 131_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.1,
outputPrice: 0.3,
},
"mistral-small-2501": {
maxTokens: 32_000,
contextWindow: 32_000,
@@ -1151,54 +815,6 @@ export const liteLlmModelInfoSaneDefaults: ModelInfo = {
outputPrice: 0,
}
// AskSage Models
// https://docs.asksage.ai/
export type AskSageModelId = keyof typeof askSageModels
export const askSageDefaultModelId: AskSageModelId = "claude-35-sonnet"
export const askSageDefaultURL: string = "https://api.asksage.ai/server"
export const askSageModels = {
"gpt-4o": {
maxTokens: 4096,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gpt-4o-gov": {
maxTokens: 4096,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"claude-35-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"aws-bedrock-claude-35-sonnet-gov": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"claude-37-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
}
// X AI
// https://docs.x.ai/docs/api-reference
export type XAIModelId = keyof typeof xaiModels
@@ -1277,90 +893,3 @@ export const xaiModels = {
description: "X AI's Grok Beta model (legacy) with 131K context window",
},
} as const satisfies Record<string, ModelInfo>
// SambaNova
// https://docs.sambanova.ai/cloud/docs/get-started/supported-models
export type SambanovaModelId = keyof typeof sambanovaModels
export const sambanovaDefaultModelId: SambanovaModelId = "Meta-Llama-3.3-70B-Instruct"
export const sambanovaModels = {
"Meta-Llama-3.3-70B-Instruct": {
maxTokens: 4096,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"DeepSeek-R1-Distill-Llama-70B": {
maxTokens: 4096,
contextWindow: 32_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Llama-3.1-Swallow-70B-Instruct-v0.3": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Llama-3.1-Swallow-8B-Instruct-v0.3": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Meta-Llama-3.1-405B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Meta-Llama-3.1-8B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Meta-Llama-3.2-1B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Qwen2.5-72B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Qwen2.5-Coder-32B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"QwQ-32B-Preview": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
} as const satisfies Record<string, ModelInfo>
-34
View File
@@ -20,37 +20,3 @@ export function findLast<T>(array: Array<T>, predicate: (value: T, index: number
const index = findLastIndex(array, predicate)
return index === -1 ? undefined : array[index]
}
/**
* Converts a partial or complete stringified array into an actual array.
* Handles both complete JSON strings and incomplete array strings.
* Splits on the specific tokens: [" ", " "]
* @param arrayString A string representation of an array, which may be incomplete
* @returns Array of strings parsed from the input
*/
export function parsePartialArrayString(arrayString: string): string[] {
try {
// Try parsing as complete JSON first
return JSON.parse(arrayString)
} catch {
// If JSON parsing fails, handle as partial string
const trimmed = arrayString.trim()
if (!trimmed.startsWith('["')) {
return []
}
// Remove leading ["
let content = trimmed.slice(2)
// Remove trailing "] if it exists
content = content.replace(/"]$/, "")
if (!content) {
return []
}
// Split on ", " token and handle the parts
return content
.split('", "')
.map((item) => item.trim())
.filter(Boolean)
}
}
-3
View File
@@ -1,5 +1,3 @@
export const DEFAULT_MCP_TIMEOUT_SECONDS = 60 // matches Anthropic's default timeout in their MCP SDK
export const MIN_MCP_TIMEOUT_SECONDS = 1
export type McpMode = "full" | "server-use-only" | "off"
export type McpServer = {
@@ -11,7 +9,6 @@ export type McpServer = {
resources?: McpResource[]
resourceTemplates?: McpResourceTemplate[]
disabled?: boolean
timeout?: number
}
export type McpTool = {
+1 -21
View File
@@ -3,7 +3,7 @@ import { after, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import "should"
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "./fs"
import { createDirectoriesForFile, fileExistsAtPath } from "./fs"
describe("Filesystem Utilities", () => {
const tmpDir = path.join(os.tmpdir(), "cline-test-" + Math.random().toString(36).slice(2))
@@ -68,24 +68,4 @@ describe("Filesystem Utilities", () => {
exists.should.be.true()
})
})
describe("isDirectory", () => {
it("should return true for directories", async () => {
await fs.mkdir(tmpDir, { recursive: true })
const isDir = await isDirectory(tmpDir)
isDir.should.be.true()
})
it("should return false for files", async () => {
const testFile = path.join(tmpDir, "test.txt")
await fs.writeFile(testFile, "test")
const isDir = await isDirectory(testFile)
isDir.should.be.false()
})
it("should return false for non-existent paths", async () => {
const nonExistentPath = path.join(tmpDir, "does-not-exist")
const isDir = await isDirectory(nonExistentPath)
isDir.should.be.false()
})
})
})
-29
View File
@@ -45,32 +45,3 @@ export async function fileExistsAtPath(filePath: string): Promise<boolean> {
return false
}
}
/**
* Checks if the path is a directory
* @param filePath - The path to check.
* @returns A promise that resolves to true if the path is a directory, false otherwise.
*/
export async function isDirectory(filePath: string): Promise<boolean> {
try {
const stats = await fs.stat(filePath)
return stats.isDirectory()
} catch {
return false
}
}
/**
* Gets the size of a file in kilobytes
* @param filePath - Path to the file to check
* @returns Promise<number> - Size of the file in KB, or 0 if file doesn't exist
*/
export async function getFileSizeInKB(filePath: string): Promise<number> {
try {
const stats = await fs.stat(filePath)
const fileSizeInKB = stats.size / 1000 // Convert bytes to KB (decimal) - matches OS file size display
return fileSizeInKB
} catch {
return 0
}
}
-21
View File
@@ -1,21 +0,0 @@
import path from "path"
import getFolderSize from "get-folder-size"
/**
* Gets the total size of tasks and checkpoints directories
* @param storagePath The base storage path (typically globalStorageUri.fsPath)
* @returns The total size in bytes, or null if calculation fails
*/
export async function getTotalTasksSize(storagePath: string): Promise<number | null> {
const tasksDir = path.join(storagePath, "tasks")
const checkpointsDir = path.join(storagePath, "checkpoints")
try {
const tasksSize = await getFolderSize.loose(tasksDir)
const checkpointsSize = await getFolderSize.loose(checkpointsDir)
return tasksSize + checkpointsSize
} catch (error) {
console.error("Failed to calculate total task size:", error)
return null
}
}
-3
View File
@@ -1,3 +0,0 @@
export function secondsToMs(seconds: number): number {
return seconds * 1000
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { anthropicModels } from "../shared/api"
import { ANTHROPIC_THINKING_BUDGET_TOKENS_MIN, anthropicModels } from "../shared/api"
/**
* Validates the thinking budget token value according to the specified rules:
@@ -21,8 +21,8 @@ export function validateThinkingBudget(
}
// If enabled but less than minimum, set to minimum
if (value > 0 && value < 1024) {
return 1024
if (value > 0 && value < ANTHROPIC_THINKING_BUDGET_TOKENS_MIN) {
return ANTHROPIC_THINKING_BUDGET_TOKENS_MIN
}
// If greater than or equal to max allowed tokens (80% of max tokens), cap at that value
+16 -1529
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -18,7 +18,6 @@
"debounce": "^2.1.1",
"dompurify": "^3.2.4",
"fast-deep-equal": "^3.1.3",
"firebase": "^11.3.0",
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
"mermaid": "^11.4.1",
@@ -35,7 +34,6 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@tailwindcss/vite": "^4.0.12",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
@@ -50,7 +48,6 @@
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"tailwindcss": "^4.0.12",
"typescript": "^5.7.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.1.1",
+1 -4
View File
@@ -7,7 +7,6 @@ import SettingsView from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView"
import AccountView from "./components/account/AccountView"
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
import { FirebaseAuthProvider } from "./context/FirebaseAuthContext"
import { vscode } from "./utils/vscode"
import McpView from "./components/mcp/McpView"
@@ -113,9 +112,7 @@ const AppContent = () => {
const App = () => {
return (
<ExtensionStateContextProvider>
<FirebaseAuthProvider>
<AppContent />
</FirebaseAuthProvider>
<AppContent />
</ExtensionStateContextProvider>
)
}
+36 -127
View File
@@ -1,14 +1,23 @@
import { VSCodeButton, VSCodeDivider } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { memo } from "react"
import { useFirebaseAuth } from "../../context/FirebaseAuthContext"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
type AccountViewProps = {
onDone: () => void
}
const AccountView = ({ onDone }: AccountViewProps) => {
const { isLoggedIn, userInfo } = useExtensionState()
const handleLogin = () => {
vscode.postMessage({ type: "accountLoginClicked" })
}
const handleLogout = () => {
vscode.postMessage({ type: "accountLogoutClicked" })
}
return (
<div
style={{
@@ -30,7 +39,7 @@ const AccountView = ({ onDone }: AccountViewProps) => {
marginBottom: "17px",
paddingRight: 17,
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Cline Account</h3>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Account</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div
@@ -42,133 +51,33 @@ const AccountView = ({ onDone }: AccountViewProps) => {
flexDirection: "column",
}}>
<div style={{ marginBottom: 5 }}>
<ClineAccountView />
{isLoggedIn ? (
<>
{userInfo?.photoURL && (
<img
src={userInfo.photoURL}
alt="Profile"
style={{
width: 48,
height: 48,
borderRadius: "50%",
marginBottom: 10,
}}
/>
)}
<div style={{ fontSize: "14px", marginBottom: 10 }}>
{userInfo?.displayName && <div>Name: {userInfo.displayName}</div>}
{userInfo?.email && <div>Email: {userInfo.email}</div>}
</div>
<VSCodeButton onClick={handleLogout}>Log out</VSCodeButton>
</>
) : (
<VSCodeButton onClick={handleLogin}>Log in to Cline</VSCodeButton>
)}
</div>
</div>
</div>
)
}
export const ClineAccountView = () => {
const { user, handleSignOut } = useFirebaseAuth()
const handleLogin = () => {
vscode.postMessage({ type: "accountLoginClicked" })
}
const handleLogout = () => {
// First notify extension to clear API keys and state
vscode.postMessage({ type: "accountLogoutClicked" })
// Then sign out of Firebase
handleSignOut()
}
return (
<div style={{ maxWidth: "600px" }}>
{user ? (
<div
style={{
padding: "8px 10px",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
backgroundColor: "var(--vscode-dropdown-background)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}>
{user.photoURL ? (
<img
src={user.photoURL}
alt="Profile"
style={{
width: 38,
height: 38,
borderRadius: "50%",
}}
/>
) : (
<div
style={{
width: 38,
height: 38,
borderRadius: "50%",
backgroundColor: "var(--vscode-button-background)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "20px",
color: "var(--vscode-button-foreground)",
}}>
{user.displayName?.[0] || user.email?.[0] || "?"}
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}>
{user.displayName && (
<div
style={{
fontSize: "13px",
fontWeight: "bold",
color: "var(--vscode-foreground)",
}}>
{user.displayName}
</div>
)}
{user.email && (
<div
style={{
fontSize: "13px",
color: "var(--vscode-descriptionForeground)",
}}>
{user.email}
</div>
)}
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<VSCodeButtonLink
href="https://app.cline.bot/credits"
appearance="primary"
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
width: "fit-content",
marginTop: 2,
marginBottom: 0,
marginRight: -12,
}}>
Account
</VSCodeButtonLink>
<VSCodeButton
appearance="secondary"
onClick={handleLogout}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
width: "fit-content",
marginTop: 2,
marginBottom: 0,
marginRight: -12,
}}>
Log out
</VSCodeButton>
</div>
</div>
</div>
</div>
) : (
<div style={{}}>
<VSCodeButton onClick={handleLogin} style={{ marginTop: 0 }}>
Sign Up with Cline
</VSCodeButton>
</div>
)}
</div>
)
}
export default memo(AccountView)
+102 -160
View File
@@ -5,10 +5,8 @@ import { useEvent, useSize } from "react-use"
import styled from "styled-components"
import {
ClineApiReqInfo,
ClineAskQuestion,
ClineAskUseMcpServer,
ClineMessage,
ClinePlanModeResponse,
ClineSayTool,
COMPLETION_RESULT_CHANGES_FLAG,
ExtensionMessage,
@@ -17,18 +15,17 @@ import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "../../../../src/s
import { useExtensionState } from "../../context/ExtensionStateContext"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "../../utils/mcp"
import { vscode } from "../../utils/vscode"
import { CheckmarkControl } from "../common/CheckmarkControl"
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import MarkdownBlock from "../common/MarkdownBlock"
import SuccessButton from "../common/SuccessButton"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
import CreditLimitError from "./CreditLimitError"
import { OptionsButtons } from "./OptionsButtons"
import { highlightMentions } from "./TaskHeader"
import SuccessButton from "../common/SuccessButton"
import { CheckmarkControl } from "../common/CheckmarkControl"
import McpResponseDisplay from "../mcp/McpResponseDisplay"
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
@@ -286,25 +283,31 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
) : (
<ProgressIndicator />
),
(() => {
if (apiReqCancelReason != null) {
return apiReqCancelReason === "user_cancelled" ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request Cancelled</span>
) : (
<span style={{ color: errorColor, fontWeight: "bold" }}>API Streaming Failed</span>
)
}
if (cost != null) {
return <span style={{ color: normalColor, fontWeight: "bold" }}>API Request</span>
}
if (apiRequestFailedMessage) {
return <span style={{ color: errorColor, fontWeight: "bold" }}>API Request Failed</span>
}
return <span style={{ color: normalColor, fontWeight: "bold" }}>API Request...</span>
})(),
apiReqCancelReason != null ? (
apiReqCancelReason === "user_cancelled" ? (
<span
style={{
color: normalColor,
fontWeight: "bold",
}}>
API Request Cancelled
</span>
) : (
<span
style={{
color: errorColor,
fontWeight: "bold",
}}>
API Streaming Failed
</span>
)
) : cost != null ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request</span>
) : apiRequestFailedMessage ? (
<span style={{ color: errorColor, fontWeight: "bold" }}>API Request Failed</span>
) : (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request...</span>
),
]
case "followup":
return [
@@ -726,55 +729,62 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
{((cost == null && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && (
<>
{(() => {
// Try to parse the error message as JSON for credit limit error
const errorData = parseErrorText(apiRequestFailedMessage)
if (errorData) {
if (
errorData.code === "insufficient_credits" &&
typeof errorData.current_balance === "number" &&
typeof errorData.total_spent === "number" &&
typeof errorData.total_promotions === "number" &&
typeof errorData.message === "string"
) {
return (
<CreditLimitError
currentBalance={errorData.current_balance}
totalSpent={errorData.total_spent}
totalPromotions={errorData.total_promotions}
message={errorData.message}
/>
)
}
}
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
// Default error display
return (
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<>
<br />
<br />
It seems like you're having Windows PowerShell issues, please see this{" "}
{/* {apiProvider === "" && (
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor:
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
color: "var(--vscode-editor-foreground)",
padding: "6px 8px",
borderRadius: "3px",
margin: "10px 0 0 0",
fontSize: "12px",
}}>
<i
className="codicon codicon-warning"
style={{
marginRight: 6,
fontSize: 16,
color: "var(--vscode-errorForeground)",
}}></i>
<span>
Uh-oh this could be a problem on end. We've been alerted and
will resolve this ASAP. You can also{" "}
<a
href="https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22"
style={{
color: "inherit",
textDecoration: "underline",
}}>
troubleshooting guide
href=""
style={{ color: "inherit", textDecoration: "underline" }}>
contact us
</a>
.
</>
)}
</p>
)
})()}
</span>
</div>
)} */}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<>
<br />
<br />
It seems like you're having Windows PowerShell issues, please see this{" "}
<a
href="https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22"
style={{
color: "inherit",
textDecoration: "underline",
}}>
troubleshooting guide
</a>
.
</>
)}
</p>
</>
)}
@@ -792,30 +802,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
)
case "api_req_finished":
return null // we should never see this message type
// case "mcp_server_response":
// return <McpResponseDisplay responseText={message.text || ""} />
case "mcp_server_response":
return (
<>
<div style={{ paddingTop: 0 }}>
<div
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "12px",
textTransform: "uppercase",
}}>
Response
</div>
<CodeAccordian
code={message.text}
language="json"
isExpanded={true}
onToggleExpand={onToggleExpand}
/>
</div>
</>
)
return <McpResponseDisplay responseText={message.text || ""} />
case "text":
return (
<div>
@@ -839,7 +827,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{isExpanded ? (
<div style={{ marginTop: -3 }}>
<span style={{ fontWeight: "bold", display: "block", marginBottom: "4px" }}>
Thinking
Reasoning
<span
className="codicon codicon-chevron-down"
style={{
@@ -853,7 +841,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
) : (
<div style={{ display: "flex", alignItems: "center" }}>
<span style={{ fontWeight: "bold", marginRight: "4px" }}>Thinking:</span>
<span style={{ fontWeight: "bold", marginRight: "4px" }}>Reasoning:</span>
<span
style={{
whiteSpace: "nowrap",
@@ -936,12 +924,10 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
style={{
display: "flex",
flexDirection: "column",
backgroundColor: "var(--vscode-textBlockQuote-background)",
backgroundColor: "rgba(255, 191, 0, 0.1)",
padding: 8,
borderRadius: 3,
fontSize: 12,
color: "var(--vscode-foreground)",
opacity: 0.8,
}}>
<div
style={{
@@ -950,15 +936,24 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
marginBottom: 4,
}}>
<i
className="codicon codicon-warning"
className="codicon codicon-error"
style={{
marginRight: 8,
fontSize: 14,
color: "var(--vscode-descriptionForeground)",
fontSize: 18,
color: "#FFA500",
}}></i>
<span style={{ fontWeight: 500 }}>Diff Edit Mismatch</span>
<span
style={{
fontWeight: 500,
color: "#FFA500",
}}>
Diff Edit Failed
</span>
</div>
<div>
This usually happens when the model uses search patterns that don't match anything in the
file. Retrying...
</div>
<div>The model used search patterns that don't match anything in the file. Retrying...</div>
</div>
</>
)
@@ -1041,8 +1036,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
})
}}
style={{
cursor: seeNewChangesDisabled ? "wait" : "pointer",
width: "100%",
cursor: seeNewChangesDisabled ? "wait" : "pointer",
}}>
<i className="codicon codicon-new-file" style={{ marginRight: 6 }} />
See new changes
@@ -1200,19 +1195,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
return null // Don't render anything when we get a completion_result ask without text
}
case "followup":
let question: string | undefined
let options: string[] | undefined
let selected: string | undefined
try {
const parsedMessage = JSON.parse(message.text || "{}") as ClineAskQuestion
question = parsedMessage.question
options = parsedMessage.options
selected = parsedMessage.selected
} catch (e) {
// legacy messages would pass question directly
question = message.text
}
return (
<>
{title && (
@@ -1222,58 +1204,18 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
)}
<div style={{ paddingTop: 10 }}>
<Markdown markdown={question} />
<OptionsButtons
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "followup"}
/>
<Markdown markdown={message.text} />
</div>
</>
)
case "plan_mode_response": {
let response: string | undefined
let options: string[] | undefined
let selected: string | undefined
try {
const parsedMessage = JSON.parse(message.text || "{}") as ClinePlanModeResponse
response = parsedMessage.response
options = parsedMessage.options
selected = parsedMessage.selected
} catch (e) {
// legacy messages would pass response directly
response = message.text
}
case "plan_mode_response":
return (
<div style={{}}>
<Markdown markdown={response} />
<OptionsButtons
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "plan_mode_response"}
/>
<Markdown markdown={message.text} />
</div>
)
}
default:
return null
}
}
}
function parseErrorText(text: string | undefined) {
if (!text) {
return undefined
}
try {
const startIndex = text.indexOf("{")
const endIndex = text.lastIndexOf("}")
if (startIndex !== -1 && endIndex !== -1) {
const jsonStr = text.substring(startIndex, endIndex + 1)
const errorObject = JSON.parse(jsonStr)
return errorObject
}
} catch (e) {
// Not JSON or missing required fields
}
}
@@ -742,8 +742,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const unknownModel = "unknown"
if (!apiConfiguration) return unknownModel
switch (selectedProvider) {
case "cline":
return `${selectedProvider}:${selectedModelId}`
case "openai":
return `openai-compat:${selectedModelId}`
case "vscode-lm":
@@ -1132,7 +1130,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</ModelContainer>
</ButtonGroup>
<Tooltip
style={{ zIndex: 1000 }}
visible={shownTooltipMode !== null}
tipText={`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
hintText={`Toggle w/ ${metaKeyChar}+Shift+A`}>
+6 -9
View File
@@ -1,4 +1,4 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
@@ -797,14 +797,11 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
<div style={{ padding: "0 20px", flexShrink: 0 }}>
<h2>What can I do for you?</h2>
<p>
Thanks to{" "}
<VSCodeLink href="https://www.anthropic.com/claude/sonnet" style={{ display: "inline" }}>
Claude 3.7 Sonnet's
</VSCodeLink>
agentic coding capabilities, I can handle complex software development tasks step-by-step. With tools
that let me create & edit files, explore complex projects, use a browser, and execute terminal
commands (after you grant permission), I can assist you in ways that go beyond code completion or tech
support. I can even use MCP to create new tools and extend my own capabilities.
Thanks to Claude 3.7 Sonnet's agentic coding capabilities, I can handle complex software development
tasks step-by-step. With tools that let me create & edit files, explore complex projects, use the
browser, and execute terminal commands (after you grant permission), I can assist you in ways that go
beyond code completion or tech support. I can even use MCP to create new tools and extend my own
capabilities.
</p>
</div>
{taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
@@ -1,60 +0,0 @@
import React from "react"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "../../utils/vscode"
import { Invoke } from "../../../../src/shared/ExtensionMessage"
interface CreditLimitErrorProps {
currentBalance: number
totalSpent: number
totalPromotions: number
message: string
}
const CreditLimitError: React.FC<CreditLimitErrorProps> = ({ currentBalance, totalSpent, totalPromotions, message }) => {
return (
<div
style={{
backgroundColor: "var(--vscode-textBlockQuote-background)",
padding: "12px",
borderRadius: "4px",
marginBottom: "12px",
}}>
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "8px" }}>{message}</div>
<div style={{ marginBottom: "12px" }}>
<div style={{ color: "var(--vscode-foreground)" }}>
Current Balance: <span style={{ fontWeight: "bold" }}>${currentBalance.toFixed(2)}</span>
</div>
<div style={{ color: "var(--vscode-foreground)" }}>Total Spent: ${totalSpent.toFixed(2)}</div>
<div style={{ color: "var(--vscode-foreground)" }}>Total Promotions: ${totalPromotions.toFixed(2)}</div>
</div>
<VSCodeButtonLink
href="https://app.cline.bot/credits"
style={{
width: "100%",
marginBottom: "8px",
}}>
<span className="codicon codicon-credit-card" style={{ fontSize: "14px", marginRight: "6px" }} />
Buy Credits
</VSCodeButtonLink>
<VSCodeButton
onClick={() => {
vscode.postMessage({
type: "invoke",
text: "primaryButtonClick" satisfies Invoke,
})
}}
appearance="secondary"
style={{
width: "100%",
}}>
<span className="codicon codicon-refresh" style={{ fontSize: "14px", marginRight: "6px" }} />
Retry Request
</VSCodeButton>
</div>
)
}
export default CreditLimitError

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