Compare commits

..

7 Commits

Author SHA1 Message Date
celestial-vault bec4f5f332 merge conflicts 2025-02-28 13:07:15 -08:00
celestial-vault ccd9b275f3 remove comment 2025-02-28 13:02:40 -08:00
celestial-vault c4609696e4 re-open PR 2025-02-28 13:02:25 -08:00
celestial-vault 56ff98ad2c added changeset 2025-02-24 16:48:07 -08:00
celestial-vault a245d311e1 adjust export markdown handling 2025-02-24 16:45:16 -08:00
celestial-vault d4770994b9 adjust export markdown handling 2025-02-24 16:45:00 -08:00
celestial-vault cb5c333a04 Updated Anthropic SDK and fixed types 2025-02-24 16:33:22 -08:00
201 changed files with 7432 additions and 24095 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add correct cost and tokens info to Native OpenAI and DeepSeek providers
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Update Claude Sonnet 35. -> 3.7 in README(s)
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: make AWS Bedrock authentication predictable
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add translation to CODE_OF_CONDUCT, CONTRIBUTING and README to Arabic ar-sa.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
A new MCP Marketplace display setting has been added to VSCode settings. (It is defaulted to "true")
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Added a .clinerules file with details about our most common features
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Make tags section in marketplace scrollable
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
opt: Enhance OpenAiHandler to diagnose request issues and handle empty streams
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
.clineignore should be included in .gitignore
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Migrate webview from CRA to Vite
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Change how Cline finds the path to the user's Documents folder by querying xdg-user-dir on Linux systems.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add rich MCP responses with images and link previews
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add preferred language option to settings
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
GPT 4.5 Preview added
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix checking "Support Images" setting had no effect on first click
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
feat(vertex): Add prompt caching support for Claude on Vertex AI
+11
View File
@@ -0,0 +1,11 @@
---
"claude-dev": minor
---
- **Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task).
- **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task.
- **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task.
- **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion.
No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Update anthropic SDK to the latest version
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: Anthropic's default model is 3.7
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adjustment to MCP server installation prompt
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
ADD IS_DEV and Hot Reloading to debug
+41 -287
View File
@@ -11,12 +11,10 @@ graph TB
subgraph VSCode Extension Host
subgraph Core Extension
ExtensionEntry[Extension Entry<br/>src/extension.ts]
WebviewProvider[WebviewProvider<br/>src/core/webview/index.ts]
Controller[Controller<br/>src/core/controller/index.ts]
Task[Task<br/>src/core/task/index.ts]
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
ClineClass[Cline Class<br/>src/core/Cline.ts]
GlobalState[VSCode Global State]
SecretsStorage[VSCode Secrets Storage]
McpHub[McpHub<br/>src/services/mcp/McpHub.ts]
end
subgraph Webview UI
@@ -29,101 +27,45 @@ graph TB
TaskStorage[Task Storage<br/>Per-Task Files & History]
CheckpointSystem[Git-based Checkpoints]
end
subgraph API Providers
AnthropicAPI[Anthropic]
OpenRouterAPI[OpenRouter]
BedrockAPI[AWS Bedrock]
OtherAPIs[Other Providers]
end
subgraph MCP Servers
ExternalMcpServers[External MCP Servers]
end
end
%% Core Extension Data Flow
ExtensionEntry --> WebviewProvider
WebviewProvider --> Controller
Controller --> Task
Controller --> McpHub
Task --> GlobalState
Task --> SecretsStorage
Task --> TaskStorage
Task --> CheckpointSystem
Task --> |API Requests| API Providers
McpHub --> |Connects to| ExternalMcpServers
Task --> |Uses| McpHub
ExtensionEntry --> ClineProvider
ClineProvider --> ClineClass
ClineClass --> GlobalState
ClineClass --> SecretsStorage
ClineClass --> TaskStorage
ClineClass --> CheckpointSystem
%% Webview Data Flow
WebviewApp --> ExtStateContext
ExtStateContext --> ReactComponents
%% Bidirectional Communication
WebviewProvider <-->|postMessage| ExtStateContext
ClineProvider <-->|postMessage| ExtStateContext
style GlobalState fill:#f9f,stroke:#333,stroke-width:2px
style SecretsStorage fill:#f9f,stroke:#333,stroke-width:2px
style ExtStateContext fill:#bbf,stroke:#333,stroke-width:2px
style WebviewProvider fill:#bfb,stroke:#333,stroke-width:2px
style McpHub fill:#bfb,stroke:#333,stroke-width:2px
style API Providers fill:#fdb,stroke:#333,stroke-width:2px
style ClineProvider fill:#bfb,stroke:#333,stroke-width:2px
```
## Definitions
- **Core Extension**: Anything inside the src folder, organized into modular components
- **Core Extension State**: Managed by the Controller class in src/core/controller/index.ts, which serves as the single source of truth for the extension's state. It manages multiple types of persistent storage (global state, workspace state, and secrets), handles state distribution to both the core extension and webview components, and coordinates state across multiple extension instances. This includes managing API configurations, task history, settings, and MCP configurations.
- **Webview**: Anything inside the webview-ui. All the react or view's seen by the user and user interaction components
- **Webview State**: Managed by ExtensionStateContext in webview-ui/src/context/ExtensionStateContext.tsx, which provides React components with access to the extension's state through a context provider pattern. It maintains local state for UI components, handles real-time updates through message events, manages partial message updates, and provides methods for state modifications. The context includes extension version, messages, task history, theme, API configurations, MCP servers, marketplace catalog, and workspace file paths. It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to state through a custom hook (useExtensionState).
### Core Extension Architecture
The core extension follows a clear hierarchical structure:
1. **WebviewProvider** (src/core/webview/index.ts): Manages the webview lifecycle and communication
2. **Controller** (src/core/controller/index.ts): Handles webview messages and task management
3. **Task** (src/core/task/index.ts): Executes API requests and tool operations
This architecture provides clear separation of concerns:
- WebviewProvider focuses on VSCode webview integration
- Controller manages state and coordinates tasks
- Task handles the execution of AI requests and tool operations
### WebviewProvider Implementation
The WebviewProvider class in `src/core/webview/index.ts` is responsible for:
- Managing multiple active instances through a static set (`activeInstances`)
- Handling webview lifecycle events (creation, visibility changes, disposal)
- Implementing HTML content generation with proper CSP headers
- Supporting Hot Module Replacement (HMR) for development
- Setting up message listeners between the webview and extension
The WebviewProvider maintains a reference to the Controller and delegates message handling to it. It also handles the creation of both sidebar and tab panel webviews, allowing Cline to be used in different contexts within VSCode.
- core extension: Anything inside the src folder starting with the Cline.ts file
- core extension state: Managed by the ClineProvider class in src/core/webview/ClineProvider.ts, which serves as the single source of truth for the extension's state. It manages multiple types of persistent storage (global state, workspace state, and secrets), handles state distribution to both the core extension and webview components, and coordinates state across multiple extension instances. This includes managing API configurations, task history, settings, and MCP configurations.
- webview: Anything inside the webview-ui. All the react or view's seen by the user and user interaction compone
- webview state: Managed by ExtensionStateContext in webview-ui/src/context/ExtensionStateContext.tsx, which provides React components with access to the extension's state through a context provider pattern. It maintains local state for UI components, handles real-time updates through message events, manages partial message updates, and provides methods for state modifications. The context includes extension version, messages, task history, theme, API configurations, MCP servers, marketplace catalog, and workspace file paths. It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to state through a custom hook (useExtensionState).
### Core Extension State
The `Controller` class manages multiple types of persistent storage:
The `ClineProvider` class manages multiple types of persistent storage:
- **Global State:** Stored across all VSCode instances. Used for settings and data that should persist globally.
- **Workspace State:** Specific to the current workspace. Used for task-specific data and settings.
- **Secrets:** Secure storage for sensitive information like API keys.
The `Controller` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
State synchronization between instances is handled through:
- File-based storage for task history and conversation data
- VSCode's global state API for settings and configuration
- Secrets storage for sensitive information
- Event listeners for file changes and configuration updates
The Controller implements methods for:
- Saving and loading task state
- Managing API configurations
- Handling user authentication
- Coordinating MCP server connections
- Managing task history and checkpoints
The `ClineProvider` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
### Webview State
@@ -140,65 +82,16 @@ The `ExtensionStateContext` in `webview-ui/src/context/ExtensionStateContext.tsx
It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to the state via a custom hook (`useExtensionState`).
The ExtensionStateContext handles:
- Real-time updates through message events
- Partial message updates for streaming content
- State modifications through setter methods
- Type-safe access to state through a custom hook
## Core Extension (Cline.ts)
## API Provider System
Cline supports multiple AI providers through a modular API provider system. Each provider is implemented as a separate module in the `src/api/providers/` directory and follows a common interface.
### API Provider Architecture
The API system consists of:
1. **API Handlers**: Provider-specific implementations in `src/api/providers/`
2. **API Transformers**: Stream transformation utilities in `src/api/transform/`
3. **API Configuration**: User settings for API keys and endpoints
4. **API Factory**: Builder function to create the appropriate handler
Key providers include:
- **Anthropic**: Direct integration with Claude models
- **OpenRouter**: Meta-provider supporting multiple model providers
- **AWS Bedrock**: Integration with Amazon's AI services
- **Gemini**: Google's AI models
- **Ollama**: Local model hosting
- **LM Studio**: Local model hosting
- **VSCode LM**: VSCode's built-in language models
### API Configuration Management
API configurations are stored securely:
- API keys are stored in VSCode's secrets storage
- Model selections and non-sensitive settings are stored in global state
- The Controller manages switching between providers and updating configurations
The system supports:
- Secure storage of API keys
- Model selection and configuration
- Automatic retry and error handling
- Token usage tracking and cost calculation
- Context window management
### Plan/Act Mode API Configuration
Cline supports separate model configurations for Plan and Act modes:
- Different models can be used for planning vs. execution
- The system preserves model selections when switching modes
- The Controller handles the transition between modes and updates the API configuration accordingly
## Task Execution System
The Task class is responsible for executing AI requests and tool operations. Each task runs in its own instance of the Task class, ensuring isolation and proper state management.
The Cline class is the heart of the extension, managing task execution, state persistence, and tool coordination. Each task runs in its own instance of the Cline class, ensuring isolation and proper state management.
### Task Execution Loop
The core task execution loop follows this pattern:
```typescript
class Task {
class Cline {
async initiateTaskLoop(userContent: UserContent, isNewTask: boolean) {
while (!this.abort) {
// 1. Make API request and stream response
@@ -233,7 +126,7 @@ class Task {
The streaming system handles real-time updates and partial content:
```typescript
class Task {
class Cline {
async presentAssistantMessage() {
// Handle streaming locks to prevent race conditions
if (this.presentAssistantMessageLocked) {
@@ -268,7 +161,7 @@ class Task {
Tools follow a strict execution pattern:
```typescript
class Task {
class Cline {
async executeToolWithApproval(block: ToolBlock) {
// 1. Check auto-approval settings
if (this.shouldAutoApproveTool(block.name)) {
@@ -300,7 +193,7 @@ class Task {
The system includes robust error handling:
```typescript
class Task {
class Cline {
async handleError(action: string, error: Error) {
// 1. Check if task was abandoned
if (this.abandoned) return
@@ -323,23 +216,23 @@ class Task {
### API Request & Token Management
The Task class handles API requests with built-in retry, streaming, and token management:
The Cline class handles API requests with built-in retry, streaming, and token management:
```typescript
class Task {
class Cline {
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
// 1. Wait for MCP servers to connect
await pWaitFor(() => this.controllerRef.deref()?.mcpHub?.isConnecting !== true)
await pWaitFor(() => this.providerRef.deref()?.mcpHub?.isConnecting !== true)
// 2. Manage context window
const previousRequest = this.clineMessages[previousApiReqIndex]
if (previousRequest?.text) {
const { tokensIn, tokensOut } = JSON.parse(previousRequest.text || "{}")
const { tokensIn, tokensOut } = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0)
// Truncate conversation if approaching context limit
if (totalTokens >= maxAllowedSize) {
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
this.conversationHistoryDeletedRange = getNextTruncationRange(
this.apiConversationHistory,
this.conversationHistoryDeletedRange,
totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
@@ -359,7 +252,7 @@ class Task {
} catch (error) {
// 4. Error handling with retry
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
await setTimeoutPromise(1000)
await delay(1000)
this.didAutomaticallyRetryFailedApiRequest = true
yield* this.attemptApiRequest(previousApiReqIndex)
return
@@ -406,32 +299,16 @@ Key features:
- Cost calculation
- Cache hit monitoring
### Context Management System
The Context Management System handles conversation history truncation to prevent context window overflow errors. Implemented in the `ContextManager` class, it ensures long-running conversations remain within model context limits while preserving critical context.
Key features:
1. **Model-Aware Sizing**: Dynamically adjusts based on different model context windows (64K for DeepSeek, 128K for most models, 200K for Claude).
2. **Proactive Truncation**: Monitors token usage and preemptively truncates conversations when approaching limits, maintaining buffers of 27K-40K tokens depending on the model.
3. **Intelligent Preservation**: Always preserves the original task message and maintains the user-assistant conversation structure when truncating.
4. **Adaptive Strategies**: Uses different truncation strategies based on context pressure - removing half of the conversation for moderate pressure or three-quarters for severe pressure.
5. **Error Recovery**: Includes specialized detection for context window errors from different providers with automatic retry and more aggressive truncation when needed.
### Task State & Resumption
The Task class provides robust task state management and resumption capabilities:
The Cline class provides robust task state management and resumption capabilities:
```typescript
class Task {
class Cline {
async resumeTaskFromHistory() {
// 1. Load saved state
this.clineMessages = await getSavedClineMessages(this.getContext(), this.taskId)
this.apiConversationHistory = await getSavedApiConversationHistory(this.getContext(), this.taskId)
this.clineMessages = await this.getSavedClineMessages()
this.apiConversationHistory = await this.getSavedApiConversationHistory()
// 2. Handle interrupted tool executions
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
@@ -461,14 +338,14 @@ class Task {
private async saveTaskState() {
// Save conversation history
await saveApiConversationHistory(this.getContext(), this.taskId, this.apiConversationHistory)
await saveClineMessages(this.getContext(), this.taskId, this.clineMessages)
await this.saveApiConversationHistory()
await this.saveClineMessages()
// Create checkpoint
const commitHash = await this.checkpointTracker?.commit()
// Update task history
await this.controllerRef.deref()?.updateTaskHistory({
await this.providerRef.deref()?.updateTaskHistory({
id: this.taskId,
ts: lastMessage.ts,
task: taskMessage.text,
@@ -504,54 +381,11 @@ Key aspects of task state management:
- Resources are cleaned up properly
- User is notified of state changes
## Plan/Act Mode System
Cline implements a dual-mode system that separates planning from execution:
### Mode Architecture
The Plan/Act mode system consists of:
1. **Mode State**: Stored in `chatSettings.mode` in the Controller's state
2. **Mode Switching**: Handled by `togglePlanActModeWithChatSettings` in the Controller
3. **Mode-specific Models**: Optional configuration to use different models for each mode
4. **Mode-specific Prompting**: Different system prompts for planning vs. execution
### Mode Switching Process
When switching between modes:
1. The current model configuration is saved to mode-specific state
2. The previous mode's model configuration is restored
3. The Task instance is updated with the new mode
4. The webview is notified of the mode change
5. Telemetry events are captured for analytics
### Plan Mode
Plan mode is designed for:
- Information gathering and context building
- Asking clarifying questions
- Creating detailed execution plans
- Discussing approaches with the user
In Plan mode, the AI uses the `plan_mode_respond` tool to engage in conversational planning without executing actions.
### Act Mode
Act mode is designed for:
- Executing the planned actions
- Using tools to modify files, run commands, etc.
- Implementing the solution
- Providing results and completion feedback
In Act mode, the AI has access to all tools except `plan_mode_respond` and focuses on implementation rather than discussion.
## Data Flow & State Management
### Core Extension Role
The Controller acts as the single source of truth for all persistent state. It:
The core extension (ClineProvider) acts as the single source of truth for all persistent state. It:
- Manages VSCode global state and secrets storage
- Coordinates state updates between components
- Ensures state consistency across webview reloads
@@ -560,10 +394,10 @@ The Controller acts as the single source of truth for all persistent state. It:
### Terminal Management
The Task class manages terminal instances and command execution:
The Cline class manages terminal instances and command execution:
```typescript
class Task {
class Cline {
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
// 1. Get or create terminal
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
@@ -619,10 +453,10 @@ Key features:
### Browser Session Management
The Task class handles browser automation through Puppeteer:
The Cline class handles browser automation through Puppeteer:
```typescript
class Task {
class Cline {
async executeBrowserAction(action: BrowserAction): Promise<BrowserActionResult> {
switch (action) {
case "launch":
@@ -659,93 +493,13 @@ Key aspects:
- Screenshot capture
- Error recovery
## MCP (Model Context Protocol) Integration
### MCP Architecture
The MCP system consists of:
1. **McpHub Class**: Central manager in `src/services/mcp/McpHub.ts`
2. **MCP Connections**: Manages connections to external MCP servers
3. **MCP Settings**: Configuration stored in a JSON file
4. **MCP Marketplace**: Online catalog of available MCP servers
5. **MCP Tools & Resources**: Capabilities exposed by connected servers
The McpHub class:
- Manages the lifecycle of MCP server connections
- Handles server configuration through a settings file
- Provides methods for calling tools and accessing resources
- Implements auto-approval settings for MCP tools
- Monitors server health and handles reconnection
### MCP Server Types
Cline supports two types of MCP server connections:
- **Stdio**: Command-line based servers that communicate via standard I/O
- **SSE**: HTTP-based servers that communicate via Server-Sent Events
### MCP Server Management
The McpHub class provides methods for:
- Discovering and connecting to MCP servers
- Monitoring server health and status
- Restarting servers when needed
- Managing server configurations
- Setting timeouts and auto-approval rules
### MCP Tool Integration
MCP tools are integrated into the Task execution system:
- Tools are discovered and registered at connection time
- The Task class can call MCP tools through the McpHub
- Tool results are streamed back to the AI
- Auto-approval settings can be configured per tool
### MCP Marketplace
The MCP Marketplace provides:
- A catalog of available MCP servers
- One-click installation
- README previews
- Server status monitoring
The Controller class manages MCP servers through the McpHub service:
```typescript
class Controller {
mcpHub?: McpHub
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, webviewProvider: WebviewProvider) {
this.mcpHub = new McpHub(this)
}
async downloadMcp(mcpId: string) {
// Fetch server details from marketplace
const response = await axios.post<McpDownloadResponse>(
"https://api.cline.bot/v1/mcp/download",
{ mcpId },
{
headers: { "Content-Type": "application/json" },
timeout: 10000,
}
)
// Create task with context from README
const task = `Set up the MCP server from ${mcpDetails.githubUrl}...`
// Initialize task and show chat view
await this.initClineWithTask(task)
}
}
```
## Conclusion
This guide provides a comprehensive overview of the Cline extension architecture, with special focus on state management, data persistence, and code organization. Following these patterns ensures robust feature implementation with proper state handling across the extension's components.
Remember:
- Always persist important state in the extension
- The core extension follows a WebviewProvider -> Controller -> Task flow
- The core extension exists in the src/ folder
- Use proper typing for all state and messages
- Handle errors and edge cases
- Test state persistence across webview reloads
+1 -1
View File
@@ -1 +1 @@
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash
-8
View File
@@ -31,14 +31,6 @@ body:
label: Relevant API REQUEST output
description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: input
id: provider-model
attributes:
label: Provider/Model
description: What provider and model were you using when the issue occurred?
placeholder: "e.g., cline:anthropic/claude-3.7-sonnet, gemini:gemini-2.5-pro-exp-03-25"
validations:
required: true
- type: input
id: operating-system
attributes:
@@ -1,19 +0,0 @@
"""
Coverage utility package for GitHub Actions workflows.
This package handles extracting coverage percentages, comparing them, and generating PR comments.
"""
# Import external dependencies
import requests
# Import main function for CLI usage
from .__main__ import main
# Import functions from extraction module
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
# Import functions from github_api module
from .github_api import generate_comment, post_comment, set_github_output
# Import functions from workflow module
from .workflow import process_coverage_workflow
-154
View File
@@ -1,154 +0,0 @@
"""
Main module.
This module provides the CLI interface for the coverage utility script.
"""
import sys
import argparse
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
from .github_api import generate_comment, post_comment, set_github_output
from .workflow import process_coverage_workflow
from .util import log
def setup_verbose_mode(args):
"""
Set up verbose mode based on command line arguments.
Args:
args: Parsed command line arguments
"""
if getattr(args, 'verbose', False):
set_verbose(True)
log("Verbose mode enabled")
def main():
# Create parent parser with common arguments
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
# Create main parser that inherits common arguments
parser = argparse.ArgumentParser(description='Coverage utility script for GitHub Actions workflows', parents=[parent_parser])
subparsers = parser.add_subparsers(dest='command', help='Command to run')
# extract-coverage command - used directly in workflow
extract_parser = subparsers.add_parser('extract-coverage', help='Extract coverage percentage from a file', parents=[parent_parser])
extract_parser.add_argument('file_path', help='Path to the coverage report file')
extract_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
help='Type of coverage report')
extract_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# compare-coverage command - used by process-workflow
compare_parser = subparsers.add_parser('compare-coverage', help='Compare coverage percentages', parents=[parent_parser])
compare_parser.add_argument('base_cov', help='Base branch coverage percentage')
compare_parser.add_argument('pr_cov', help='PR branch coverage percentage')
compare_parser.add_argument('--output-prefix', default='', help='Prefix for GitHub Actions output variables')
compare_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# generate-comment command - used by process-workflow
comment_parser = subparsers.add_parser('generate-comment', help='Generate PR comment with coverage comparison', parents=[parent_parser])
comment_parser.add_argument('base_ext_cov', help='Base branch extension coverage')
comment_parser.add_argument('pr_ext_cov', help='PR branch extension coverage')
comment_parser.add_argument('ext_decreased', help='Whether extension coverage decreased (true/false)')
comment_parser.add_argument('ext_diff', help='Extension coverage difference')
comment_parser.add_argument('base_web_cov', help='Base branch webview coverage')
comment_parser.add_argument('pr_web_cov', help='PR branch webview coverage')
comment_parser.add_argument('web_decreased', help='Whether webview coverage decreased (true/false)')
comment_parser.add_argument('web_diff', help='Webview coverage difference')
# post-comment command - used by process-workflow
post_parser = subparsers.add_parser('post-comment', help='Post a comment to a GitHub PR', parents=[parent_parser])
post_parser.add_argument('comment_path', help='Path to the file containing the comment text')
post_parser.add_argument('pr_number', help='PR number')
post_parser.add_argument('repo', help='Repository in the format "owner/repo"')
post_parser.add_argument('--token', help='GitHub token')
# run-coverage command - used by process-workflow
run_parser = subparsers.add_parser('run-coverage', help='Run a coverage command and extract the coverage percentage', parents=[parent_parser])
run_parser.add_argument('coverage_cmd', help='Command to run')
run_parser.add_argument('output_file', help='File to save the output to')
run_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
help='Type of coverage report')
run_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# process-workflow command - used directly in workflow
workflow_parser = subparsers.add_parser('process-workflow', help='Process the entire coverage workflow', parents=[parent_parser])
workflow_parser.add_argument('--base-branch', required=True, help='Base branch name')
workflow_parser.add_argument('--pr-number', help='PR number')
workflow_parser.add_argument('--repo', help='Repository in the format "owner/repo"')
workflow_parser.add_argument('--token', help='GitHub token')
# set-github-output command - used by process-workflow
output_parser = subparsers.add_parser('set-github-output', help='Set GitHub Actions output variable', parents=[parent_parser])
output_parser.add_argument('name', help='Output variable name')
output_parser.add_argument('value', help='Output variable value')
args = parser.parse_args()
# Set up verbose mode
setup_verbose_mode(args)
if args.command == 'extract-coverage':
log(f"Extracting coverage from file: {args.file_path} (type: {args.type})")
coverage_pct = extract_coverage(args.file_path, args.type)
if args.github_output:
set_github_output(f"{args.type}_coverage", coverage_pct)
else:
log(f"Coverage: {coverage_pct}%")
elif args.command == 'compare-coverage':
log(f"Comparing coverage: base={args.base_cov}%, PR={args.pr_cov}%")
decreased, diff = compare_coverage(args.base_cov, args.pr_cov)
if args.github_output:
prefix = args.output_prefix
set_github_output(f"{prefix}decreased", str(decreased).lower())
set_github_output(f"{prefix}diff", diff)
log(f"Coverage difference: {diff}%")
log(f"Coverage decreased: {decreased}")
else:
log(f"decreased={str(decreased).lower()}")
log(f"diff={diff}")
elif args.command == 'generate-comment':
log("Generating coverage comparison comment")
comment = generate_comment(
args.base_ext_cov, args.pr_ext_cov, args.ext_decreased, args.ext_diff,
args.base_web_cov, args.pr_web_cov, args.web_decreased, args.web_diff
)
# Output the comment to stdout
log(comment)
elif args.command == 'post-comment':
log(f"Posting comment from {args.comment_path} to PR #{args.pr_number} in {args.repo}")
post_comment(args.comment_path, args.pr_number, args.repo, args.token)
elif args.command == 'run-coverage':
log(f"Running coverage command: {args.coverage_cmd}")
log(f"Output file: {args.output_file}")
log(f"Coverage type: {args.type}")
coverage_pct = run_coverage(args.coverage_cmd, args.output_file, args.type)
if args.github_output:
set_github_output(f"{args.type}_coverage", coverage_pct)
else:
log(f"Coverage: {coverage_pct}%")
elif args.command == 'process-workflow':
log("Processing coverage workflow")
log(f"Base branch: {args.base_branch}")
if args.pr_number:
log(f"PR number: {args.pr_number}")
if args.repo:
log(f"Repository: {args.repo}")
process_coverage_workflow(args)
elif args.command == 'set-github-output':
log(f"Setting GitHub output: {args.name}={args.value}")
set_github_output(args.name, args.value)
else:
log("No command specified")
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,265 +0,0 @@
"""
Coverage extraction module.
This module handles extracting coverage percentages from coverage report files.
"""
import os
import re
import sys
import shlex
import subprocess
import traceback
from .util import log, file_exists, get_file_size, list_directory, is_safe_command, run_command
# Global verbose flag
verbose = False
def set_verbose(value):
"""Set the global verbose flag."""
global verbose
verbose = value
def print_debug_output(content, coverage_type):
"""
Print debug information about the coverage output.
Args:
content: The content of the coverage file
coverage_type: Type of coverage report (extension or webview)
"""
if not verbose:
return
# Extract and print only the coverage summary section
if coverage_type == "extension":
# Look for the coverage summary section
summary_match = re.search(r'=============================== Coverage summary ===============================\n(.*?)\n=+', content, re.DOTALL)
if summary_match:
sys.stdout.write("\n##[group]EXTENSION COVERAGE SUMMARY\n")
sys.stdout.write("=============================== Coverage summary ===============================\n")
sys.stdout.write(summary_match.group(1) + "\n")
sys.stdout.write("================================================================================\n")
sys.stdout.write("##[endgroup]\n")
sys.stdout.flush()
else:
sys.stdout.write("\n##[warning]No coverage summary found in extension coverage file\n")
sys.stdout.flush()
else: # webview
# Look for the coverage table - specifically the "All files" row
table_match = re.search(r'% Coverage report from v8.*?-+\|.*?\n.*?\n(All files.*?)(?:\n[^\n]*\|)', content, re.DOTALL)
if table_match:
sys.stdout.write("\n##[group]WEBVIEW COVERAGE SUMMARY\n")
sys.stdout.write("% Coverage report from v8\n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write("File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s \n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write(table_match.group(1) + "\n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write("##[endgroup]\n")
sys.stdout.flush()
else:
sys.stdout.write("\n##[warning]No coverage table found in webview coverage file\n")
sys.stdout.flush()
def extract_coverage(file_path, coverage_type="extension"):
"""
Extract coverage percentage from a coverage report file.
Args:
file_path: Path to the coverage report file
coverage_type: Type of coverage report (extension or webview)
Returns:
Coverage percentage as a float
"""
# Always print file path for debugging
log(f"Checking coverage file: {file_path}")
# Check if file exists and get its size
if not file_exists(file_path):
sys.stdout.write(f"\n##[error]File {file_path} does not exist\n")
sys.stdout.flush()
log(f"Error: File {file_path} does not exist")
# Check if the directory exists
dir_path = os.path.dirname(file_path)
if not os.path.exists(dir_path):
sys.stdout.write(f"\n##[error]Directory {dir_path} does not exist\n")
sys.stdout.flush()
log(f"Error: Directory {dir_path} does not exist")
else:
# List directory contents for debugging
log(f"Directory {dir_path} exists, listing contents:")
try:
dir_contents = list_directory(dir_path)
for name, size in dir_contents:
log(f" {name} - {size}")
sys.stdout.write(f" {name} - {size}\n")
sys.stdout.flush()
except Exception as e:
log(f"Error listing directory: {e}")
return 0.0
file_size = get_file_size(file_path)
log(f"File size: {file_size} bytes")
sys.stdout.write(f"\n##[info]Coverage file {file_path} exists, size: {file_size} bytes\n")
sys.stdout.flush()
if file_size == 0:
sys.stdout.write(f"\n##[warning]File {file_path} is empty\n")
sys.stdout.flush()
log(f"Warning: File {file_path} is empty")
return 0.0
# List directory contents for debugging
dir_path = os.path.dirname(file_path)
log(f"Directory contents of {dir_path}:")
try:
dir_contents = list_directory(dir_path)
for name, size in dir_contents:
log(f" {name} - {size}")
except Exception as e:
log(f"Error listing directory: {e}")
with open(file_path, 'r') as f:
content = f.read()
# Print debug information if verbose
print_debug_output(content, coverage_type)
# Extract coverage percentage based on coverage type
if coverage_type == "extension":
# Extract the percentage from the "Lines" row in the coverage summary
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
if lines_match:
coverage_pct = float(lines_match.group(1))
if verbose:
sys.stdout.write(f"Pattern matched (Lines percentage): {coverage_pct}\n")
sys.stdout.flush()
return coverage_pct
else:
# No coverage data found, log full content for debugging
log("No coverage data found. Full file content:")
log("=== Full file content ===")
log(content)
log("=== End file content ===")
else: # webview
# Extract the percentage from the "% Lines" column in the "All files" row
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
if all_files_match:
coverage_pct = float(all_files_match.group(1))
if verbose:
sys.stdout.write(f"Pattern matched (All files % Lines): {coverage_pct}\n")
sys.stdout.flush()
return coverage_pct
else:
# No coverage data found, log full content for debugging
log("No coverage data found. Full file content:")
log("=== Full file content ===")
log(content)
log("=== End file content ===")
# If no match found, return 0.0
return 0.0
def compare_coverage(base_cov, pr_cov):
"""
Compare coverage percentages between base and PR branches.
Args:
base_cov: Base branch coverage percentage
pr_cov: PR branch coverage percentage
Returns:
Tuple of (decreased, diff)
"""
try:
base_cov = float(base_cov)
pr_cov = float(pr_cov)
except ValueError:
sys.stdout.write(f"Error: Invalid coverage values - base: {base_cov}, PR: {pr_cov}\n")
sys.stdout.flush()
return False, 0
diff = pr_cov - base_cov
decreased = diff < 0
return decreased, abs(diff)
def run_coverage(command, output_file, coverage_type="extension"):
"""
Run a coverage command and extract the coverage percentage.
Args:
command: Command to run
output_file: File to save the output to
coverage_type: Type of coverage report (extension or webview)
Returns:
Coverage percentage as a float
Raises:
SystemExit: If the output file is not created or is empty
"""
try:
# Run the command and capture output
if not is_safe_command(command):
error_msg = f"ERROR: Unsafe command detected: {command}"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1)
# Run command using safe execution from util
returncode, stdout, stderr = run_command(command)
# Log command result
log(f"Command exit code: {returncode}")
log(f"Command stdout length: {len(stdout)} bytes")
log(f"Command stderr length: {len(stderr)} bytes")
# Save output to file
log(f"Saving command output to {output_file}")
with open(output_file, 'w') as f:
f.write(stdout)
if stderr:
f.write("\n\n=== STDERR ===\n")
f.write(stderr)
# Verify file was created and has content
if not file_exists(output_file):
error_msg = f"ERROR: Output file {output_file} was not created"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1) # Exit with error code to fail the workflow
file_size = get_file_size(output_file)
if file_size == 0:
error_msg = f"ERROR: Output file {output_file} is empty"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1) # Exit with error code to fail the workflow
log(f"Output file size: {file_size} bytes")
# Extract coverage percentage
coverage_pct = extract_coverage(output_file, coverage_type)
log(f"{coverage_type.capitalize()} coverage: {coverage_pct}%")
return coverage_pct
except Exception as e:
error_msg = f"Error running coverage command: {e}"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
# Print stack trace for debugging
log(traceback.format_exc())
sys.exit(1) # Exit with error code to fail the workflow
@@ -1,177 +0,0 @@
"""
GitHub API module.
This module handles interactions with the GitHub API for posting comments to PRs.
"""
import os
import requests
from .util import log, file_exists
def generate_comment(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff):
"""
Generate a PR comment with coverage comparison.
Args:
base_ext_cov: Base branch extension coverage
pr_ext_cov: PR branch extension coverage
ext_decreased: Whether extension coverage decreased
ext_diff: Extension coverage difference
base_web_cov: Base branch webview coverage
pr_web_cov: PR branch webview coverage
web_decreased: Whether webview coverage decreased
web_diff: Webview coverage difference
Returns:
Comment text
"""
from datetime import datetime
# Convert string inputs to appropriate types
try:
base_ext_cov = float(base_ext_cov)
pr_ext_cov = float(pr_ext_cov)
# Handle ext_decreased as either string or boolean
if isinstance(ext_decreased, str):
ext_decreased = ext_decreased.lower() == 'true'
else:
ext_decreased = bool(ext_decreased)
ext_diff = float(ext_diff)
base_web_cov = float(base_web_cov)
pr_web_cov = float(pr_web_cov)
# Handle web_decreased as either string or boolean
if isinstance(web_decreased, str):
web_decreased = web_decreased.lower() == 'true'
else:
web_decreased = bool(web_decreased)
web_diff = float(web_diff)
except ValueError as e:
log(f"Error converting input values: {e}")
return ""
# Add a unique identifier to find this comment later
comment = '<!-- COVERAGE_REPORT -->\n'
comment += '## Coverage Report\n\n'
# Extension coverage
comment += '### Extension Coverage\n\n'
comment += f'Base branch: {base_ext_cov:.0f}%\n\n'
comment += f'PR branch: {pr_ext_cov:.0f}%\n\n'
if ext_decreased:
comment += f'⚠️ **Warning: Coverage decreased by {ext_diff:.2f}%**\n\n'
comment += 'Consider adding tests to cover your changes.\n\n'
else:
comment += '✅ Coverage increased or remained the same\n\n'
# Webview coverage
comment += '### Webview Coverage\n\n'
comment += f'Base branch: {base_web_cov:.0f}%\n\n'
comment += f'PR branch: {pr_web_cov:.0f}%\n\n'
if web_decreased:
comment += f'⚠️ **Warning: Coverage decreased by {web_diff:.2f}%**\n\n'
comment += 'Consider adding tests to cover your changes.\n\n'
else:
comment += '✅ Coverage increased or remained the same\n\n'
# Overall assessment
comment += '### Overall Assessment\n\n'
if ext_decreased or web_decreased:
comment += '⚠️ **Test coverage has decreased in this PR**\n\n'
comment += 'Please consider adding tests to maintain or improve coverage.\n\n'
else:
comment += '✅ **Test coverage has been maintained or improved**\n\n'
# Add timestamp
comment += f'\n\n<sub>Last updated: {datetime.now().isoformat()}</sub>'
return comment
def post_comment(comment_path, pr_number, repo, token=None):
"""
Post a comment to a GitHub PR.
Args:
comment_path: Path to the file containing the comment text
pr_number: PR number
repo: Repository in the format "owner/repo"
token: GitHub token
"""
if not file_exists(comment_path):
log(f"Error: Comment file {comment_path} does not exist")
return
with open(comment_path, 'r') as f:
comment_body = f.read()
if not token:
token = os.environ.get('GITHUB_TOKEN')
if not token:
log("Error: GitHub token not provided")
return
# Find existing comment
headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
# Get all comments
comments_url = f'https://api.github.com/repos/{repo}/issues/{pr_number}/comments'
log(f"Getting comments from: {comments_url}")
response = requests.get(comments_url, headers=headers)
if response.status_code != 200:
log(f"Error getting comments: {response.status_code} - {response.text}")
return
comments = response.json()
log(f"Found {len(comments)} existing comments")
# Find comment with our identifier
comment_id = None
for comment in comments:
if '<!-- COVERAGE_REPORT -->' in comment['body']:
comment_id = comment['id']
log(f"Found existing coverage report comment with ID: {comment_id}")
break
if comment_id:
# Update existing comment
update_url = f'https://api.github.com/repos/{repo}/issues/comments/{comment_id}'
log(f"Updating existing comment at: {update_url}")
response = requests.patch(update_url, headers=headers, json={'body': comment_body})
if response.status_code == 200:
log(f"Successfully updated existing comment: {comment_id}")
else:
log(f"Error updating comment: {response.status_code} - {response.text}")
else:
# Create new comment
log(f"Creating new comment at: {comments_url}")
response = requests.post(comments_url, headers=headers, json={'body': comment_body})
if response.status_code == 201:
log("Successfully created new comment")
else:
log(f"Error creating comment: {response.status_code} - {response.text}")
def set_github_output(name, value):
"""
Set GitHub Actions output variable.
Args:
name: Output variable name
value: Output variable value
"""
# Write to the GitHub output file if available
if 'GITHUB_OUTPUT' in os.environ:
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f"{name}={value}\n")
else:
# Fallback to the deprecated method for backward compatibility
log(f"::set-output name={name}::{value}")
# Also print for human readability
log(f"{name}: {value}")
-245
View File
@@ -1,245 +0,0 @@
"""
Utility module.
This module provides utility functions used across the coverage check scripts.
"""
import os
import sys
import re
import shlex
import subprocess
import traceback
from typing import List, Tuple, Dict, Any, Optional, Union
# List of allowed commands and their arguments
ALLOWED_COMMANDS = {
'xvfb-run': ['-a'],
'npm': ['run', 'test:coverage', 'ci', 'install', '--no-save', '@vitest/coverage-v8', 'check-types', 'lint', 'format', 'compile'],
'cd': ['webview-ui'],
'python': ['-m', 'coverage_check'],
'git': ['fetch', 'checkout', 'origin'],
}
def is_safe_command(command: Union[str, List[str]]) -> bool:
"""
Check if a command is safe to execute.
Args:
command: Command to check (string or list)
Returns:
True if command is safe, False otherwise
"""
# Convert string command to list
if isinstance(command, str):
try:
cmd_parts = shlex.split(command)
except ValueError:
return False
else:
cmd_parts = command
if not cmd_parts:
return False
# Get base command
base_cmd = os.path.basename(cmd_parts[0])
# Check if command is in allowed list
if base_cmd not in ALLOWED_COMMANDS:
return False
# For each argument, check for suspicious patterns
for arg in cmd_parts[1:]:
# Check for shell metacharacters
if re.search(r'[;&|`$]', arg):
return False
# Check for path traversal
if '..' in arg and not (base_cmd == 'npm' and arg.startswith('@')):
return False
return True
def log(message: str) -> None:
"""
Write a message to stdout and flush.
Args:
message: The message to write
"""
sys.stdout.write(f"{message}\n")
sys.stdout.flush()
def file_exists(file_path: str) -> bool:
"""
Check if a file exists.
Args:
file_path: Path to the file
Returns:
True if the file exists, False otherwise
"""
return os.path.exists(file_path) and os.path.isfile(file_path)
def get_file_size(file_path: str) -> int:
"""
Get the size of a file in bytes.
Args:
file_path: Path to the file
Returns:
Size of the file in bytes, or 0 if the file doesn't exist
"""
if file_exists(file_path):
return os.path.getsize(file_path)
return 0
def list_directory(dir_path: str) -> List[Tuple[str, Union[int, str]]]:
"""
List the contents of a directory.
Args:
dir_path: Path to the directory
Returns:
List of (name, size) tuples for each file/directory in the directory
"""
if not os.path.exists(dir_path) or not os.path.isdir(dir_path):
return []
contents = []
for item in os.listdir(dir_path):
item_path = os.path.join(dir_path, item)
if os.path.isfile(item_path):
contents.append((item, os.path.getsize(item_path)))
else:
contents.append((item, "DIR"))
return contents
def read_file_content(file_path: str, default: str = "") -> str:
"""
Read file content with error handling.
Args:
file_path: Path to the file
default: Default value to return if file cannot be read
Returns:
File content or default value
"""
if not file_exists(file_path):
log(f"File does not exist: {file_path}")
return default
try:
with open(file_path, 'r') as f:
return f.read()
except Exception as e:
log(f"Error reading file {file_path}: {e}")
return default
def write_file_content(file_path: str, content: str) -> bool:
"""
Write content to file with error handling.
Args:
file_path: Path to the file
content: Content to write
Returns:
True if successful, False otherwise
"""
try:
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w') as f:
f.write(content)
return True
except Exception as e:
log(f"Error writing to file {file_path}: {e}")
return False
def run_command(command: Union[str, List[str]], capture_output: bool = True) -> Tuple[int, str, str]:
"""
Run a command and return the result.
Args:
command: Command to run (string or list)
capture_output: Whether to capture stdout/stderr
Returns:
Tuple of (returncode, stdout, stderr)
"""
if not is_safe_command(command):
error_msg = f"Unsafe command detected: {command}"
log(error_msg)
return 1, "", error_msg
log(f"Running command: {command}")
try:
# Convert string command to list
if isinstance(command, str):
cmd_list = shlex.split(command)
else:
cmd_list = command
result = subprocess.run(
cmd_list,
shell=False, # Never use shell=True for security
capture_output=capture_output,
text=True
)
log(f"Command exit code: {result.returncode}")
return result.returncode, result.stdout, result.stderr
except Exception as e:
log(f"Error running command: {e}")
log(traceback.format_exc())
return 1, "", str(e)
def find_pattern(content: str, pattern: str, group: int = 0,
default: Optional[str] = None) -> Optional[str]:
"""
Find a pattern in content and return the specified group.
Args:
content: Text content to search
pattern: Regex pattern to search for
group: Group number to return (default: 0 for entire match)
default: Default value to return if pattern not found
Returns:
Matched text or default value
"""
match = re.search(pattern, content, re.DOTALL)
if match:
return match.group(group)
return default
def get_env_var(name: str, default: Optional[str] = None) -> Optional[str]:
"""
Get environment variable with default value.
Args:
name: Environment variable name
default: Default value if not set
Returns:
Environment variable value or default
"""
return os.environ.get(name, default)
def format_exception(e: Exception) -> str:
"""
Format an exception with traceback for logging.
Args:
e: Exception to format
Returns:
Formatted exception string
"""
return f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
-432
View File
@@ -1,432 +0,0 @@
"""
Workflow module.
This module handles the main workflow logic for running coverage tests and processing results.
"""
import os
import re
import sys
import subprocess
import traceback
from .extraction import run_coverage, compare_coverage, extract_coverage
from .github_api import generate_comment, post_comment, set_github_output
from .util import log, file_exists, get_file_size, list_directory, run_command
def is_valid_branch_name(branch_name: str) -> bool:
"""
Validate a git branch name.
Args:
branch_name: Branch name to validate
Returns:
True if valid, False otherwise
"""
# Check for common branch name patterns
if not re.match(r'^[a-zA-Z0-9_\-./]+$', branch_name):
return False
# Check for path traversal
if '..' in branch_name:
return False
# Check for shell metacharacters
if re.search(r'[;&|`$]', branch_name):
return False
return True
def checkout_branch(branch_name: str) -> None:
"""
Checkout a branch for testing.
Args:
branch_name: Branch name to checkout
Raises:
RuntimeError: If branch checkout fails
ValueError: If branch name is invalid
"""
if not is_valid_branch_name(branch_name):
raise ValueError(f"Invalid branch name: {branch_name}")
log(f"=== Checking out branch: {branch_name} ===")
# Fetch the branch
returncode, stdout, stderr = run_command(['git', 'fetch', 'origin', branch_name])
if returncode != 0:
log(f"ERROR: Failed to fetch branch {branch_name}")
log(f"Error details: {stderr}")
raise RuntimeError(f"Git fetch failed: {stderr}")
# Checkout the branch
returncode, stdout, stderr = run_command(['git', 'checkout', branch_name])
if returncode != 0:
log(f"ERROR: Failed to checkout branch {branch_name}")
log(f"Error details: {stderr}")
raise RuntimeError(f"Git checkout failed: {stderr}")
log(f"Successfully checked out branch: {branch_name}")
def extract_extension_coverage_from_file(file_path):
"""Extract extension coverage from file when run_coverage returns 0."""
if not file_exists(file_path):
log(f"File {file_path} does not exist, cannot extract extension coverage")
return 0.0
file_size = get_file_size(file_path)
if file_size == 0:
log(f"File {file_path} is empty, cannot extract extension coverage")
return 0.0
log(f"Extension coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
with open(file_path, 'r') as f:
content = f.read()
# Extract the percentage from the "Lines" row in the coverage summary
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
if lines_match:
coverage = float(lines_match.group(1))
log(f"Found extension coverage in file: {coverage}%")
return coverage
return 0.0
def extract_webview_coverage_from_file(file_path):
"""Extract webview coverage from file when run_coverage returns 0."""
if not file_exists(file_path):
log(f"File {file_path} does not exist, cannot extract webview coverage")
return 0.0
file_size = get_file_size(file_path)
if file_size == 0:
log(f"File {file_path} is empty, cannot extract webview coverage")
return 0.0
log(f"Webview coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
with open(file_path, 'r') as f:
content = f.read()
# Extract the percentage from the "% Lines" column in the "All files" row
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
if all_files_match:
coverage = float(all_files_match.group(1))
log(f"Found webview coverage in file: {coverage}%")
return coverage
return 0.0
def run_extension_coverage(branch_name=None):
"""Run extension coverage tests and extract results."""
prefix = 'base_' if branch_name else ''
file_path = f"{prefix}extension_coverage.txt"
# Run coverage tests
ext_cov = run_coverage(
["xvfb-run", "-a", "npm", "run", "test:coverage"],
file_path,
"extension"
)
# If coverage is 0.0, try to extract from file directly
if ext_cov == 0.0:
ext_cov = extract_extension_coverage_from_file(file_path)
return ext_cov
def run_webview_coverage(branch_name=None):
"""Run webview coverage tests and extract results."""
prefix = 'base_' if branch_name else ''
file_path = f"{prefix}webview_coverage.txt"
# Save current directory
original_dir = os.getcwd()
try:
# Change to webview-ui directory
os.chdir('webview-ui')
# Install coverage dependency
returncode, stdout, stderr = run_command(["npm", "install", "--no-save", "@vitest/coverage-v8"])
if returncode != 0:
log(f"Failed to install coverage dependency: {stderr}")
return 0.0
# Run coverage tests from webview-ui directory
web_cov = run_coverage(
["npm", "run", "test:coverage"],
os.path.join('..', file_path),
"webview"
)
finally:
# Always change back to original directory
os.chdir(original_dir)
# If coverage is 0.0, try to extract from file directly
if web_cov == 0.0:
web_cov = extract_webview_coverage_from_file(file_path)
return web_cov
def run_branch_coverage(branch_name=None):
"""
Run coverage tests for a branch.
Args:
branch_name: Name of the branch to checkout before running tests (optional)
Returns:
Tuple of (extension_coverage, webview_coverage)
"""
# Checkout branch if specified
if branch_name:
checkout_branch(branch_name)
# Run coverage tests
log(f"=== Running coverage tests{' for ' + branch_name if branch_name else ''} ===")
# Run extension and webview coverage
ext_cov = run_extension_coverage(branch_name)
web_cov = run_webview_coverage(branch_name)
return ext_cov, web_cov
def find_potential_coverage_files():
"""Find potential coverage files in the current directory and webview-ui."""
log("Searching for potential coverage files...")
# Find files in current directory
current_dir_files = list_directory('.')
for name, size in current_dir_files:
if 'coverage' in name.lower() and size != "DIR":
log(f"Found potential coverage file: {name} (size: {size} bytes)")
# Find files in webview-ui directory
if os.path.exists('webview-ui') and os.path.isdir('webview-ui'):
webview_files = list_directory('webview-ui')
for name, size in webview_files:
if 'coverage' in name.lower() and size != "DIR":
log(f"Found potential webview coverage file: webview-ui/{name} (size: {size} bytes)")
else:
log("webview-ui directory not found")
def generate_warnings(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff):
"""Generate warnings for coverage decreases."""
if not (ext_decreased or web_decreased):
return []
warnings = [
"Test coverage has decreased in this PR",
f"Extension coverage: {base_ext_cov}% -> {pr_ext_cov}% (Diff: {ext_diff}%)",
f"Webview coverage: {base_web_cov}% -> {pr_web_cov}% (Diff: {web_diff}%)"
]
# Additional warning for significant decrease (more than 1%)
if ext_decreased and ext_diff > 1.0:
warnings.append(f"Extension coverage decreased by more than 1% ({ext_diff}%). Consider adding tests to cover your changes.")
if web_decreased and web_diff > 1.0:
warnings.append(f"Webview coverage decreased by more than 1% ({web_diff}%). Consider adding tests to cover your changes.")
return warnings
def output_warnings(warnings):
"""Output warnings to GitHub step summary and console."""
if not warnings:
return
# Get the GitHub step summary file path from environment variable
github_step_summary = os.environ.get('GITHUB_STEP_SUMMARY')
# Write to GitHub step summary if available
if github_step_summary:
with open(github_step_summary, 'a') as f:
f.write("## Coverage Warnings\n\n")
for warning in warnings:
f.write(f"⚠️ {warning}\n\n")
# Also output to console with ::warning:: syntax for backward compatibility
for warning in warnings:
log(f"::warning::{warning}")
def output_github_results(pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff):
"""Output results for GitHub Actions."""
set_github_output("pr_extension_coverage", pr_ext_cov)
set_github_output("pr_webview_coverage", pr_web_cov)
set_github_output("base_extension_coverage", base_ext_cov)
set_github_output("base_webview_coverage", base_web_cov)
set_github_output("extension_decreased", str(ext_decreased).lower())
set_github_output("extension_diff", ext_diff)
set_github_output("webview_decreased", str(web_decreased).lower())
set_github_output("webview_diff", web_diff)
def extract_pr_coverage_from_artifacts():
"""
Extract PR branch coverage from artifact files.
Returns:
Tuple of (extension_coverage, webview_coverage)
Raises:
SystemExit: If the coverage files don't exist
"""
log("=== Extracting PR branch coverage from artifacts ===")
# Check if the coverage files exist
ext_file_path = "extension_coverage.txt"
web_file_path = "webview-ui/webview_coverage.txt"
# Extract extension coverage
log(f"Extracting extension coverage from {ext_file_path}")
if not file_exists(ext_file_path):
error_msg = f"ERROR: PR extension coverage file {ext_file_path} not found"
log(error_msg)
# List directory contents for debugging
log("Current directory contents:")
try:
dir_contents = list_directory('.')
for name, size in dir_contents:
log(f" {name} - {size}\n")
except Exception as e:
log(f"Error listing directory: {e}")
sys.exit(1) # Exit with error code to fail the workflow
ext_cov = extract_extension_coverage_from_file(ext_file_path)
log(f"PR extension coverage from artifact: {ext_cov}%")
# Extract webview coverage
log(f"Extracting webview coverage from {web_file_path}")
if not file_exists(web_file_path):
error_msg = f"ERROR: PR webview coverage file {web_file_path} not found"
log(error_msg)
# Check if the webview-ui directory exists
if not os.path.exists('webview-ui'):
log("ERROR: webview-ui directory not found")
else:
# List webview-ui directory contents for debugging
log("webview-ui directory contents:")
try:
dir_contents = list_directory('webview-ui')
for name, size in dir_contents:
log(f" {name} - {size}")
except Exception as e:
log(f"Error listing directory: {e}")
sys.exit(1) # Exit with error code to fail the workflow
web_cov = extract_webview_coverage_from_file(web_file_path)
log(f"PR webview coverage from artifact: {web_cov}%")
return ext_cov, web_cov
def process_coverage_workflow(args):
"""
Process the entire coverage workflow.
Args:
args: Command line arguments
"""
# Initialize all variables at the start
pr_ext_cov = 0.0
pr_web_cov = 0.0
base_ext_cov = 0.0
base_web_cov = 0.0
ext_decreased = False
ext_diff = 0.0
web_decreased = False
web_diff = 0.0
try:
# Validate branch name
if not is_valid_branch_name(args.base_branch):
raise ValueError(f"Invalid base branch name: {args.base_branch}")
# Check if we're running in GitHub Actions
is_github_actions = 'GITHUB_ACTIONS' in os.environ
if is_github_actions:
log("Running in GitHub Actions environment")
# Extract PR branch coverage from artifacts (from test job)
pr_ext_cov, pr_web_cov = extract_pr_coverage_from_artifacts()
# Verify PR coverage values
if pr_ext_cov == 0.0:
log("WARNING: PR extension coverage is 0.0, this may indicate an issue with the coverage report")
find_potential_coverage_files()
if pr_web_cov == 0.0:
log("WARNING: PR webview coverage is 0.0, this may indicate an issue with the coverage report")
find_potential_coverage_files()
# Run base branch coverage
log(f"=== Running base branch coverage for {args.base_branch} ===")
base_ext_cov, base_web_cov = run_branch_coverage(args.base_branch)
# Verify base coverage values
if base_ext_cov == 0.0:
log("WARNING: Base extension coverage is 0.0, this may indicate an issue with the coverage report")
if base_web_cov == 0.0:
log("WARNING: Base webview coverage is 0.0, this may indicate an issue with the coverage report")
# Compare coverage
log("=== Comparing extension coverage ===")
ext_decreased, ext_diff = compare_coverage(base_ext_cov, pr_ext_cov)
log("=== Comparing webview coverage ===")
web_decreased, web_diff = compare_coverage(base_web_cov, pr_web_cov)
# Print summary of coverage values
log("\n=== Coverage Summary ===")
log(f"PR extension coverage: {pr_ext_cov}%")
log(f"Base extension coverage: {base_ext_cov}%")
log(f"Extension coverage change: {'+' if not ext_decreased else '-'}{ext_diff}%")
log(f"PR webview coverage: {pr_web_cov}%")
log(f"Base webview coverage: {base_web_cov}%")
log(f"Webview coverage change: {'+' if not web_decreased else '-'}{web_diff}%")
# Generate and output warnings
warnings = generate_warnings(
base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff
)
output_warnings(warnings)
# Generate comment
log("=== Generating comment ===")
comment = generate_comment(
base_ext_cov, pr_ext_cov, str(ext_decreased).lower(), ext_diff,
base_web_cov, pr_web_cov, str(web_decreased).lower(), web_diff
)
# Save comment to file
with open("coverage_comment.md", "w") as f:
f.write(comment)
# Post comment if PR number is provided
if args.pr_number:
log(f"=== Posting comment to PR #{args.pr_number} ===")
post_comment("coverage_comment.md", args.pr_number, args.repo, args.token)
# Output results for GitHub Actions
output_github_results(
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff
)
except Exception as e:
log(f"ERROR in process_coverage_workflow: {e}")
traceback.print_exc()
# Try to output results even if there was an error
try:
output_github_results(
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff
)
except Exception as e2:
log(f"ERROR outputting GitHub results: {e2}")
@@ -22,6 +22,7 @@ Environment Variables:
#!/usr/bin/env python3
import os
import sys
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
VERSION = os.environ['VERSION']
@@ -31,49 +32,72 @@ NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
def overwrite_changelog_section(changelog_text: str, new_content: str):
# Find the section for the specified version
version_pattern = f"## {VERSION}\n"
unformmatted_prev_version_pattern = f"## {PREV_VERSION}\n"
bracketed_version_pattern = f"## [{VERSION}]\n"
prev_version_pattern = f"## [{PREV_VERSION}]\n"
print(f"latest version: {VERSION}")
print(f"prev_version: {PREV_VERSION}")
notes_start_index = changelog_text.find(version_pattern) + len(version_pattern)
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and (prev_version_pattern in changelog_text or unformmatted_prev_version_pattern in changelog_text) else len(changelog_text)
# Try both unbracketed and bracketed version patterns
version_index = changelog_text.find(version_pattern)
if version_index == -1:
version_index = changelog_text.find(bracketed_version_pattern)
if version_index == -1:
# If version not found, add it at the top (after the first line)
first_newline = changelog_text.find('\n')
if first_newline == -1:
# If no newline found, just prepend
return f"## [{VERSION}]\n\n{changelog_text}"
return f"{changelog_text[:first_newline + 1]}## [{VERSION}]\n\n{changelog_text[first_newline + 1:]}"
else:
# Using bracketed version
version_pattern = bracketed_version_pattern
notes_start_index = version_index + len(version_pattern)
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and prev_version_pattern in changelog_text else len(changelog_text)
if new_content:
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
else:
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
filtered_lines = []
for line in changeset_lines:
# If the previous line is a changeset format
if len(filtered_lines) > 1 and filtered_lines[-1].startswith("### "):
# Remove the last two lines from the filted_lines
filtered_lines.pop()
filtered_lines.pop()
else:
filtered_lines.append(line.strip())
# Prepend a new line to the first line of filtered_lines
if filtered_lines:
filtered_lines[0] = "\n" + filtered_lines[0]
# Print filted_lines wiht a "\n" at the end of each line
for line in filtered_lines:
print(line.strip())
parsed_lines = "\n".join(line for line in filtered_lines)
# Ensure we have at least 2 lines before removing them
if len(changeset_lines) < 2:
print("Warning: Changeset content has fewer than 2 lines")
parsed_lines = "\n".join(changeset_lines)
else:
# Remove the first two lines from the regular changeset format, ex: \n### Patch Changes
parsed_lines = "\n".join(changeset_lines[2:])
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
# Ensure version number is bracketed
updated_changelog = updated_changelog.replace(f"## {VERSION}", f"## [{VERSION}]")
return updated_changelog
with open(CHANGELOG_PATH, 'r') as f:
changelog_content = f.read()
try:
print(f"Reading changelog from: {CHANGELOG_PATH}")
with open(CHANGELOG_PATH, 'r') as f:
changelog_content = f.read()
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
# print("----------------------------------------------------------------------------------")
# print(new_changelog)
# print("----------------------------------------------------------------------------------")
# Write back to CHANGELOG.md
with open(CHANGELOG_PATH, 'w') as f:
f.write(new_changelog)
print(f"Changelog content length: {len(changelog_content)} characters")
print("First 200 characters of changelog:")
print(changelog_content[:200])
print("----------------------------------------------------------------------------------")
print(f"{CHANGELOG_PATH} updated successfully!")
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
print("New changelog content:")
print("----------------------------------------------------------------------------------")
print(new_changelog)
print("----------------------------------------------------------------------------------")
print(f"Writing updated changelog back to: {CHANGELOG_PATH}")
with open(CHANGELOG_PATH, 'w') as f:
f.write(new_changelog)
print(f"{CHANGELOG_PATH} updated successfully!")
except FileNotFoundError:
print(f"Error: Changelog file not found at {CHANGELOG_PATH}")
sys.exit(1)
except Exception as e:
print(f"Error updating changelog: {str(e)}")
print(f"Current working directory: {os.getcwd()}")
sys.exit(1)
@@ -1,282 +0,0 @@
#!/usr/bin/env python3
"""
Tests for coverage_check script.
"""
import os
import sys
import unittest
import subprocess
import tempfile
from unittest.mock import patch, MagicMock, call, mock_open
# Add parent directory to path so we can import coverage modules
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from coverage_check import extract_coverage, compare_coverage, set_verbose, generate_comment, post_comment, set_github_output
from coverage_check.util import log, file_exists, get_file_size, list_directory
class TestCoverage(unittest.TestCase):
# Class variables to store coverage files
temp_dir = None
extension_coverage_file = None
webview_coverage_file = None
@classmethod
def setUpClass(cls):
"""Set up test environment once for all tests."""
# Create temporary directory for test files
cls.temp_dir = tempfile.TemporaryDirectory()
cls.extension_coverage_file = os.path.join(cls.temp_dir.name, 'extension_coverage.txt')
cls.webview_coverage_file = os.path.join(cls.temp_dir.name, 'webview_coverage.txt')
# Run actual tests to generate coverage reports
cls.generate_coverage_reports()
# Verify files exist and are not empty
assert os.path.exists(cls.extension_coverage_file), \
f"Extension coverage file {cls.extension_coverage_file} does not exist"
assert os.path.getsize(cls.extension_coverage_file) > 0, \
f"Extension coverage file {cls.extension_coverage_file} is empty"
assert os.path.exists(cls.webview_coverage_file), \
f"Webview coverage file {cls.webview_coverage_file} does not exist"
assert os.path.getsize(cls.webview_coverage_file) > 0, \
f"Webview coverage file {cls.webview_coverage_file} is empty"
@classmethod
def tearDownClass(cls):
"""Clean up test environment after all tests."""
if cls.temp_dir:
cls.temp_dir.cleanup()
@classmethod
def generate_coverage_reports(cls):
"""Generate real coverage reports by running tests."""
log("Generating coverage reports (this may take a while)...")
# Run extension tests with coverage
try:
# Get absolute paths
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))
webview_dir = os.path.join(root_dir, 'webview-ui')
# Use xvfb-run on Linux
if sys.platform.startswith('linux'):
cmd = f"cd {root_dir} && xvfb-run -a npm run test:coverage > {cls.extension_coverage_file} 2>&1"
else:
cmd = f"cd {root_dir} && npm run test:coverage > {cls.extension_coverage_file} 2>&1"
log("Running extension tests...")
log(f"Command: {cmd}")
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
log(f"Extension tests exit code: {result.returncode}")
# Run webview tests with coverage
log("Running webview tests...")
cmd = f"cd {webview_dir} && npm run test:coverage > {cls.webview_coverage_file} 2>&1"
log(f"Command: {cmd}")
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
log(f"Webview tests exit code: {result.returncode}")
# Verify files were created
if file_exists(cls.extension_coverage_file):
ext_size = get_file_size(cls.extension_coverage_file)
log(f"Extension coverage file created: {cls.extension_coverage_file} (size: {ext_size} bytes)")
else:
log(f"WARNING: Extension coverage file was not created: {cls.extension_coverage_file}")
if file_exists(cls.webview_coverage_file):
web_size = get_file_size(cls.webview_coverage_file)
log(f"Webview coverage file created: {cls.webview_coverage_file} (size: {web_size} bytes)")
else:
log(f"WARNING: Webview coverage file was not created: {cls.webview_coverage_file}")
log("Coverage reports generation completed.")
except Exception as e:
log(f"Error generating coverage reports: {e}")
import traceback
log(traceback.format_exc())
# Create empty files if tests fail
log("Creating fallback coverage files...")
with open(cls.extension_coverage_file, 'w') as f:
f.write("No coverage data available")
with open(cls.webview_coverage_file, 'w') as f:
f.write("No coverage data available")
def test_extract_coverage(self):
"""Test extract_coverage function with both extension and webview coverage."""
# Check if verbose mode is enabled
if '-v' in sys.argv or '--verbose' in sys.argv:
set_verbose(True)
# Verify files exist before testing
self.assertTrue(file_exists(self.extension_coverage_file),
f"Extension coverage file does not exist: {self.extension_coverage_file}")
self.assertTrue(file_exists(self.webview_coverage_file),
f"Webview coverage file does not exist: {self.webview_coverage_file}")
# Log file sizes
ext_size = get_file_size(self.extension_coverage_file)
web_size = get_file_size(self.webview_coverage_file)
log(f"Extension coverage file size: {ext_size} bytes")
log(f"Webview coverage file size: {web_size} bytes")
# Test extension coverage
log("Testing extension coverage extraction...")
ext_coverage_pct = extract_coverage(self.extension_coverage_file, 'extension')
# Check that coverage percentage is a float
self.assertIsInstance(ext_coverage_pct, float)
# Check that coverage percentage is between 0 and 100
self.assertGreaterEqual(ext_coverage_pct, 0)
self.assertLessEqual(ext_coverage_pct, 100)
# Log coverage percentage for debugging
log(f"Extension coverage: {ext_coverage_pct}%")
# Test webview coverage
log("Testing webview coverage extraction...")
web_coverage_pct = extract_coverage(self.webview_coverage_file, 'webview')
# Convert to float if it's an integer
if isinstance(web_coverage_pct, int):
web_coverage_pct = float(web_coverage_pct)
# Check that coverage percentage is a float
self.assertIsInstance(web_coverage_pct, float)
# Check that coverage percentage is between 0 and 100
self.assertGreaterEqual(web_coverage_pct, 0)
self.assertLessEqual(web_coverage_pct, 100)
# Log coverage percentage for debugging
log(f"Webview coverage: {web_coverage_pct}%")
def test_compare_coverage(self):
"""Test compare_coverage function."""
# Test with coverage increase
decreased, diff = compare_coverage(80, 90)
self.assertFalse(decreased)
self.assertEqual(diff, 10)
# Test with coverage decrease
decreased, diff = compare_coverage(90, 80)
self.assertTrue(decreased)
self.assertEqual(diff, 10)
# Test with no change
decreased, diff = compare_coverage(80, 80)
self.assertFalse(decreased)
self.assertEqual(diff, 0)
def test_generate_comment(self):
"""Test generate_comment function."""
comment = generate_comment(
80, 90, 'false', 10,
70, 75, 'false', 5
)
# Check that comment contains expected sections
self.assertIn('Coverage Report', comment)
self.assertIn('Extension Coverage', comment)
self.assertIn('Webview Coverage', comment)
self.assertIn('Overall Assessment', comment)
# Check that comment contains coverage percentages
self.assertIn('Base branch: 80%', comment)
self.assertIn('PR branch: 90%', comment)
self.assertIn('Base branch: 70%', comment)
self.assertIn('PR branch: 75%', comment)
# Check that comment contains correct assessment
self.assertIn('Coverage increased or remained the same', comment)
self.assertIn('Test coverage has been maintained or improved', comment)
@patch('coverage_check.requests.get')
@patch('coverage_check.requests.post')
@patch('coverage_check.requests.patch')
def test_post_comment_new(self, mock_patch, mock_post, mock_get):
"""Test post_comment function when creating a new comment."""
# Create a temporary comment file
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
with open(comment_file, 'w') as f:
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
# Mock the API responses
mock_get.return_value = MagicMock(status_code=200, json=lambda: [])
mock_post.return_value = MagicMock(status_code=201)
# Test post_comment function
post_comment(comment_file, '123', 'owner/repo', 'token')
# Check that the correct API calls were made
mock_get.assert_called_once()
mock_post.assert_called_once()
mock_patch.assert_not_called()
@patch('coverage_check.requests.get')
@patch('coverage_check.requests.post')
@patch('coverage_check.requests.patch')
def test_post_comment_update(self, mock_patch, mock_post, mock_get):
"""Test post_comment function when updating an existing comment."""
# Create a temporary comment file
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
with open(comment_file, 'w') as f:
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
# Mock the API responses
mock_get.return_value = MagicMock(
status_code=200,
json=lambda: [{'id': 456, 'body': '<!-- COVERAGE_REPORT -->\nOld comment'}]
)
mock_patch.return_value = MagicMock(status_code=200)
# Test post_comment function
post_comment(comment_file, '123', 'owner/repo', 'token')
# Check that the correct API calls were made
mock_get.assert_called_once()
mock_patch.assert_called_once()
mock_post.assert_not_called()
def test_set_github_output(self):
"""Test set_github_output function."""
# Capture stdout
with patch('sys.stdout', new=MagicMock()) as mock_stdout:
# Mock environment without GITHUB_OUTPUT
with patch.dict('os.environ', {}, clear=True):
set_github_output('test_name', 'test_value')
# Check that the correct output was printed to stdout
mock_stdout.assert_has_calls([
# GitHub Actions output format (deprecated method)
call.write('::set-output name=test_name::test_value\n'),
call.flush(),
# Human readable format
call.write('test_name: test_value\n'),
call.flush()
], any_order=False)
# Reset mock for next test
mock_stdout.reset_mock()
# Test with GITHUB_OUTPUT environment variable
with patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/github_output'}), \
patch('builtins.open', mock_open()) as mock_file:
set_github_output('test_name', 'test_value')
# Check that file was written to
mock_file.assert_called_once_with('/tmp/github_output', 'a')
mock_file().write.assert_called_once_with('test_name=test_value\n')
# Check that human readable output was printed
mock_stdout.assert_has_calls([
call.write('test_name: test_value\n'),
call.flush()
], any_order=False)
if __name__ == '__main__':
unittest.main()
-91
View File
@@ -1,91 +0,0 @@
name: Changeset Converter
run-name: Changeset Conversion
on:
pull_request:
types: [closed]
env:
REPO_PATH: ${{ github.repository }}
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
NODE_VERSION: 20.18.1
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 != 'github-actions'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Git Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.GIT_REF }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install Dependencies
run: npm install changeset
# 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: Create Changeset Pull Request
if: steps.check-changesets.outputs.new_changesets != '0'
uses: changesets/action@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 }}
# 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
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 to Pull Request
run: |
git config user.name "github-actions"
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 "--------------------------------------------------------------------------------"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
git push origin $CURRENT_BRANCH
+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 }}
+117
View File
@@ -0,0 +1,117 @@
name: Check Changeset
run-name: Check for Changeset in PR
permissions:
contents: read
pull-requests: write
on:
pull_request:
branches:
- main
types: [opened, synchronize, reopened, ready_for_review]
jobs:
check-changeset:
# Skip draft PRs and dependabot PRs
if: github.event.pull_request.draft == false && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Check for changeset
id: check-changeset
run: |
# Debug info
echo "Current directory: $(pwd)"
echo "PR Base Ref: ${{ github.event.pull_request.base.ref }}"
echo "PR Head Ref: ${{ github.event.pull_request.head.ref }}"
echo "PR Head SHA: ${{ github.event.pull_request.head.sha }}"
echo "Git status:"
git status
# Get list of changed files
git fetch origin ${{ github.event.pull_request.base.ref }}
CHANGED_FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} HEAD)
echo "Changed files:"
echo "$CHANGED_FILES"
# Check if any of the changed files are in docs/ or .github/
echo "Checking if changes are docs-only..."
DOCS_ONLY=true
while IFS= read -r file; do
if [[ ! "$file" =~ ^(docs/|.github/) ]]; then
echo "Found non-docs change: $file"
DOCS_ONLY=false
break
fi
done <<< "$CHANGED_FILES"
# If changes are docs-only, skip changeset check
if [ "$DOCS_ONLY" = true ]; then
echo "All changes are in docs/ or .github/, skipping changeset check"
exit 0
else
echo "Changes include non-docs files, checking for changeset..."
fi
# Check if any changeset files are in the changed files
echo "Checking for changeset files in changed files..."
CHANGESET_IN_PR=false
while IFS= read -r file; do
if [[ "$file" =~ ^\.changeset/.*\.md$ && "$file" != ".changeset/README.md" && "$file" != ".changeset/config.json" ]]; then
echo "Found changeset file in PR: $file"
CHANGESET_IN_PR=true
break
fi
done <<< "$CHANGED_FILES"
if [ "$CHANGESET_IN_PR" = false ]; then
echo "No changeset files found in changed files. Changed files in .changeset/:"
echo "$CHANGED_FILES" | grep "^\.changeset/" || true
echo "::error::No changeset file found in PR changes. Please run 'npm run changeset' to create one."
exit 1
fi
- name: Comment on PR
if: failure()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const message = `This PR requires a changeset since it includes user-facing changes. Please:
1. Run \`npm run changeset\` locally
2. Choose the appropriate version bump:
- \`major\` for breaking changes (1.0.0 → 2.0.0)
- \`minor\` for new features (1.0.0 → 1.1.0)
- \`patch\` for bug fixes (1.0.0 → 1.0.1)
3. Write a clear description of your changes
4. Commit the generated changeset file
Note: Documentation-only changes do not require a changeset.`;
// Get existing comments
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
// Check if we already commented
const botComment = comments.data.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('This PR requires a changeset')
);
if (!botComment) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
}
+2 -126
View File
@@ -25,17 +25,6 @@ jobs:
with:
node-version: 20.15.1
# Setup Python for coverage script
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install requests
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
@@ -69,118 +58,5 @@ jobs:
- name: Prettier / Format Check
run: npm run format
# Build the extension before running tests
- name: Build Extension
run: npm run compile
- name: Unit Tests
run: npm run test:unit
# Run extension tests with coverage
- name: Extension Tests with Coverage
id: extension_coverage
continue-on-error: true
run: |
xvfb-run -a npm run test:coverage > extension_coverage.txt 2>&1 || true
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
# Run webview tests with coverage
- name: Webview Tests with Coverage
id: webview_coverage
continue-on-error: true
run: |
cd webview-ui
# Ensure coverage dependency is installed
npm install --no-save @vitest/coverage-v8
npm run test:coverage > webview_coverage.txt 2>&1 || true
cd ..
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
# Save coverage reports as artifacts (workflow-scoped)
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
with:
name: pr-coverage-reports
path: |
extension_coverage.txt
webview-ui/webview_coverage.txt
retention-period: workflow # Artifacts are automatically deleted when the workflow completes
coverage:
needs: test
runs-on: ubuntu-latest
# Only run on PRs to main branch
if: github.event_name == 'pull_request' && github.base_ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for accurate comparison
# Setup Python for coverage script
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20.15.1
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
# Build the extension before running tests
- name: Build Extension
run: npm run compile
# Download coverage artifacts from test job
- name: Download Coverage Reports
uses: actions/download-artifact@v4
with:
name: pr-coverage-reports
path: . # Download to root directory to match expected paths
# Process coverage workflow
- name: Process coverage workflow
id: coverage
run: |
# Extract PR number from GITHUB_REF
PR_NUMBER=$(echo "$GITHUB_REF" | sed -e 's/refs\/pull\///' -e 's/\/merge//')
# Run the coverage workflow from root directory
PYTHONPATH=.github/scripts python -m coverage_check process-workflow \
--base-branch ${{ github.base_ref }} \
--pr-number $PR_NUMBER \
--repo $GITHUB_REPOSITORY \
--token ${{ secrets.GITHUB_TOKEN }} \
--verbose
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Extension Tests
run: xvfb-run -a npm run test
+1 -8
View File
@@ -9,11 +9,4 @@ tmp
pnpm-lock.yaml
.clineignore
.venv
.actrc
# Ignore coverage directories and files
coverage
# But don't ignore the coverage scripts in .github/scripts/
!.github/scripts/coverage/
.clineignore
-6
View File
@@ -1,6 +0,0 @@
{
"extension": ["ts"],
"spec": "src/**/__tests__/*.ts",
"require": ["ts-node/register", "source-map-support/register"],
"recursive": true
}
-118
View File
@@ -1,123 +1,5 @@
# Changelog
## [3.8.7]
- Add Enable extended thinking for LiteLLM provider (Thanks @jorgegarciarey!)
- Add a tab for configuring local MCP Servers
- Fix issue with DeepSeek API provider token counting + context management
- Fix issues with checkpoints hanging under certain conditions
## [3.8.6]
- Add UI for adding remote servers
- Add Mentions Feature Guide and update related documentation
- Fix bug where menu would open in sidebar and open tab
- Fix issue with Cline accounts not showing user info in popout tabs
- Fix bug where menu buttons wouldn't open view in sidebar
## [3.8.5]
- Add support for remote MCP Servers using SSE
- Add gemini-2.5-pro-exp-03-25 to Vertex AI (thanks @arri-cc!)
- Add access to history, mcp, and new task buttons in popout view
- Add task feedback telemetry (thumbs up/down on task completion)
- Add toggle disabled for remote servers
- Move the MCP Restart and Delete buttons and add an auto-approve all toggle
- Update Requestly UX for model selection (thanks @arafatkatze!)
- Add escape for html content for gemini when running commands
- Improve search and replace edit failure behaviors
## [3.8.4]
- Add Sambanova Deepseek-V3-0324
- Add cost calculation support for LiteLLM provider
- Fix bug where Cline would use plan_mode_response bug without response parameter
## [3.8.3]
- Add support for SambaNova QwQ-32B model
- Add OpenAI "dynamic" model chatgpt-4o-latest
- Add Amazon Nova models to AWS Bedrock
- Improve file handling for NextJS folder naming (fixes issues with parentheses in folder names)
- Add Gemini 2.5 Pro to Google AI Studio available models
- Handle "input too large" errors for Anthropic
- Fix "See more" not showing up for tasks after task un-fold
- Fix gpt-4.5-preview's supportsPromptCache value to true
## [3.8.2]
- Fix bug where switching to plan/act would result in VS Code LM/OpenRouter model being reset
## [3.8.0]
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
- Improve rich MCP display with dynamic image loading and support for GIFs
- Add 'Documentation' menu item to easily access Cline's docs
- Add OpenRouter's new usage_details feature for more reliable cost reporting
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
- Fix bug where OpenRouter model ID would be set to invalid value
- Add button to delete MCP servers in a failure state
## [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!)
## [3.4.10]
- Add support for GPT-4.5 preview model
## [3.4.9]
- Add toggle to let users opt-in to anonymous telemetry and error reporting
-30
View File
@@ -31,36 +31,6 @@ If you're planning to work on a bigger feature, please create a [feature request
- Run `npm run test` to run tests locally
- Before submitting PR, run `npm run format:fix` to format your code
3. **Linux-specific Setup**
VS Code extension tests on Linux require the following system libraries:
- `libatk1.0-0`
- `libatk-bridge2.0-0`
- `libxkbfile1`
- `libx11-xcb1`
- `libxcomposite1`
- `libxdamage1`
- `libxfixes3`
- `libxrandr2`
- `libgbm1`
- `libdrm2`
- `libgtk-3-0`
- `dbus`
- `xvfb`
These libraries provide necessary GUI components and system services for the test environment.
For example, on Debian-based distributions (e.g., Ubuntu), you can install these libraries using apt:
```bash
sudo apt update
sudo apt install -y \
libatk1.0-0 libatk-bridge2.0-0 libxkbfile1 libx11-xcb1 \
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 \
libdrm2 libgtk-3-0 dbus xvfb
```
- Run `npm run test:ci` to run tests locally
## Writing and Submitting Code
Anyone can contribute code to Cline, but we ask that you follow these guidelines to ensure your contributions can be smoothly integrated:
+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
+3 -1
View File
@@ -1 +1,3 @@
See [https://cline.bot/privacy](https://cline.bot/privacy) for our privacy policy.
View our Privacy Policy on our website.
[Privacy Policy](https://cline.bot/privacy)
-1
View File
@@ -18,7 +18,6 @@ Welcome to the Cline documentation - your comprehensive guide to using and exten
- **Understand Cline's capabilities:**
- [Cline Tools Guide](tools/cline-tools-guide.md)
- [Mentions Feature Guide](tools/mentions-guide.md)
- **Extend Cline with MCP Servers:**
- [MCP Overview](mcp/README.md)
+3
View File
@@ -0,0 +1,3 @@
View our Terms of Service on our website.
[Terms of Service](https://cline.bot/tos)
+26 -52
View File
@@ -1,67 +1,41 @@
flowchart TB
subgraph "VSCode Extension Host"
subgraph "Core Extension"
ExtensionEntry["Extension Entry<br/>src/extension.ts"]
WebviewProvider["WebviewProvider<br/>src/core/webview/index.ts"]
Controller["Controller<br/>src/core/controller/index.ts"]
Task["Task<br/>src/core/task/index.ts"]
GlobalState["VSCode Global State"]
SecretsStorage["VSCode Secrets Storage"]
McpHub["McpHub<br/>src/services/mcp/McpHub.ts"]
graph TB
subgraph VSCode Extension Host
subgraph Core Extension
ExtensionEntry[Extension Entry<br/>src/extension.ts]
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
ClineClass[Cline Class<br/>src/core/Cline.ts]
GlobalState[VSCode Global State]
SecretsStorage[VSCode Secrets Storage]
end
subgraph "Webview UI"
WebviewApp["React App<br/>webview-ui/src/App.tsx"]
ExtStateContext["ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx"]
ReactComponents["React Components"]
subgraph Webview UI
WebviewApp[React App<br/>webview-ui/src/App.tsx]
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
ReactComponents[React Components]
end
subgraph "Storage"
TaskStorage["Task Storage<br/>Per-Task Files & History"]
CheckpointSystem["Git-based Checkpoints"]
end
subgraph "API Providers"
AnthropicAPI["Anthropic"]
OpenRouterAPI["OpenRouter"]
BedrockAPI["AWS Bedrock"]
OtherAPIs["Other Providers"]
end
subgraph "MCP Servers"
ExternalMcpServers["External MCP Servers"]
subgraph Storage
TaskStorage[Task Storage<br/>Per-Task Files & History]
CheckpointSystem[Git-based Checkpoints]
end
end
%% Core Extension Data Flow
ExtensionEntry --> WebviewProvider
WebviewProvider --> Controller
Controller --> Task
Controller --> McpHub
Task --> GlobalState
Task --> SecretsStorage
Task --> TaskStorage
Task --> CheckpointSystem
Task --> |"API Requests"| AnthropicAPI
Task --> |"API Requests"| OpenRouterAPI
Task --> |"API Requests"| BedrockAPI
Task --> |"API Requests"| OtherAPIs
McpHub --> |"Connects to"| ExternalMcpServers
Task --> |"Uses"| McpHub
ExtensionEntry --> ClineProvider
ClineProvider --> ClineClass
ClineClass --> GlobalState
ClineClass --> SecretsStorage
ClineClass --> TaskStorage
ClineClass --> CheckpointSystem
%% Webview Data Flow
WebviewApp --> ExtStateContext
ExtStateContext --> ReactComponents
%% Bidirectional Communication
WebviewProvider <--> |"postMessage"| ExtStateContext
ClineProvider <-->|postMessage| ExtStateContext
classDef vscodeState fill:#f9f,stroke:#333,stroke-width:2px
classDef contextClass fill:#bbf,stroke:#333,stroke-width:2px
classDef providerClass fill:#bfb,stroke:#333,stroke-width:2px
classDef apiClass fill:#fdb,stroke:#333,stroke-width:2px
class GlobalState,SecretsStorage vscodeState
class ExtStateContext contextClass
class WebviewProvider,McpHub providerClass
class AnthropicAPI,OpenRouterAPI,BedrockAPI,OtherAPIs apiClass
style GlobalState fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
style SecretsStorage fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
style ExtStateContext fill:#0066ff,stroke:#333,stroke-width:2px,color:#ffffff
style ClineProvider fill:#00cc66,stroke:#333,stroke-width:2px,color:#ffffff
+1 -1
View File
@@ -28,7 +28,7 @@ There are multiple places online to find MCP servers:
2. **Example Interaction with Cline:**
```
User: "Cline, I want to add the MCP server for Brave browser control. Here's the GitHub link: https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search Can you add it?"
User: "Cline, I want to add the MCP server for Brave browser control. Here's the GitHub link: https://github.com/modelcontextprotocol/servers/tree/main/src/brave Can you add it?"
Cline: "OK. Cloning the repository to the MCP directory. It needs to be built because it has a 'package.json' file. Should I run 'npm run build'?"
-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.
+2 -3
View File
@@ -20,9 +20,8 @@ Cline is your AI assistant that can:
2. **Provide Context**
- Use @ mentions to add files, folders, URLs, diagnostics, terminal output, and more
- Example: "@/src/components/App.tsx"
- See the [Mentions Feature Guide](./mentions-guide.md) for details
- Use @ mentions to add files, folders, or URLs
- Example: "@file:src/components/App.tsx"
3. **Review Changes**
- Cline will show diffs before making changes
-205
View File
@@ -1,205 +0,0 @@
# Cline Mentions Feature Guide
## Overview
The mentions feature is a powerful capability that allows you to reference various resources in your conversations with Cline using the "@" symbol. This includes file contents, directory structures, webpage URLs, VSCode diagnostic information, terminal output, Git change status, and more - all easily incorporated into your conversations.
By using this feature, Cline can gain more accurate context and provide more relevant assistance for your tasks.
## Basic Syntax
Mentions always start with the "@" symbol, followed by the path or identifier of the resource you want to reference:
```
@resource_identifier
```
You can place mentions anywhere in your user messages, and Cline will automatically retrieve the referenced content.
## Supported Mention Types
### 1. File References
To reference file contents, use `@/` followed by the relative path within your project:
```
@/path/to/file.js
```
**Example:**
```
Please analyze the implementation in @/src/components/Button.tsx
```
In this example, Cline automatically retrieves the contents of Button.tsx and uses it to perform the analysis.
### 2. Directory References
To reference directory contents, use `@/` followed by the relative path of the directory, ending with a trailing `/`:
```
@/path/to/directory/
```
**Example:**
```
What components are available in the @/src/components/ directory?
```
In this example, Cline retrieves a listing of the components directory and its contents.
### 3. URL References
To reference web page contents, use `@` followed by the URL:
```
@https://example.com
```
**Example:**
```
Please parse the JSON response from @https://api.github.com/users/octocat
```
In this example, Cline fetches the response from the GitHub API and analyzes the JSON.
### 4. Diagnostic References
To reference VSCode diagnostic information (errors and warnings) in the current workspace, use `@problems`:
```
@problems
```
**Example:**
```
Check @problems and tell me which errors I should prioritize fixing
```
In this example, Cline retrieves the current errors and warnings from your workspace and identifies high-priority issues.
### 5. Terminal Output References
To reference the latest terminal output, use `@terminal`:
```
@terminal
```
**Example:**
```
Please identify the cause of the error in the @terminal output
```
In this example, Cline examines the latest terminal output and analyzes the error's cause.
### 6. Git Working Directory References
To reference the current Git working directory change status, use `@git-changes`:
```
@git-changes
```
**Example:**
```
Review the @git-changes and summarize the important changes that should be committed
```
In this example, Cline retrieves the list of changed files in the current Git working directory and identifies candidates for commit.
### 7. Git Commit References
To reference information about a specific Git commit, use `@` followed by the commit hash:
```
@commit_hash
```
**Example:**
```
Analyze the commit @abcd123 and explain what changes were made
```
In this example, Cline retrieves information about the specified commit hash and analyzes the changes made in that commit.
## Usage Scenarios
### Code Review
```
Check @/src/components/Form.jsx and suggest improvements from a performance perspective. Also, if there are any @problems, please suggest how to fix them.
```
### Debugging Assistance
```
My npm install failed. Please examine the @terminal output and suggest a solution to the problem.
```
### Project Analysis
```
Analyze the code in the @/src/models/ directory and explain the relationships between the data models. Also, tell me how the utility functions in @/src/utils/ are used with these models.
```
### Code Generation
```
Create a new Input.tsx component using the same design language as @/src/components/Button.tsx
```
### Version Control Integration
```
Review the @git-changes and suggest a commit message for the feature I'm working on.
```
## Combining Multiple Mentions
You can combine multiple mentions to provide more complex context:
```
There seems to be a bug in @/src/api/users.js. Please check @problems and @terminal to identify and fix the issue.
```
## Limitations and Considerations
1. **Large Files**: Referencing very large files may take time to process and could consume a significant amount of tokens.
2. **Binary Files**: Binary files (such as images) will not be properly processed and will show a "Binary file" message.
3. **Directory Structure**: Directory references will only show top-level files and directories, not recursively showing the contents of subdirectories.
4. **URL Limitations**: Some websites may block automated crawling, which could prevent accurate content retrieval.
5. **Path Syntax**: File paths or URLs with special characters (such as spaces) may not be recognized correctly.
## Troubleshooting
### Mentions Not Recognized
If your mentions aren't being recognized correctly, check that:
- There's no space after the `@` symbol
- File paths are accurate (case-sensitive)
- URLs include the full format (with `https://`)
### Content Not Retrieved
If the content of referenced resources can't be retrieved:
- Verify the file exists
- Ensure you have access permissions for the file
- Check that the file isn't too large or the URL too complex
### Performance Issues
If mention processing is slow:
- Reference smaller files or specific file sections
- Reduce the number of mentions used at once
## Conclusion
Mastering the mentions feature makes your communication with Cline more efficient. By providing appropriate context, Cline can deliver more accurate assistance, significantly improving your development workflow.
-9
View File
@@ -52,7 +52,6 @@ const copyWasmFiles = {
"java",
"php",
"swift",
"kotlin",
]
languages.forEach((lang) => {
@@ -72,14 +71,6 @@ const extensionConfig = {
copyWasmFiles,
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
{
name: "alias-plugin",
setup(build) {
build.onResolve({ filter: /^pkce-challenge$/ }, (args) => {
return { path: require.resolve("pkce-challenge/dist/index.browser.js") }
})
},
},
],
entryPoints: ["src/extension.ts"],
format: "cjs",
+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)
+23 -25
View File
@@ -2,48 +2,46 @@
## 我們的承諾
為了營造開放且友善的環境,我們為貢獻者維護者承諾讓參與本專案及社群的體驗,對每個人都不帶有騷擾,不論年齡、體型、身心障礙、族裔、性徵、性別認同與表現、經驗程度、教育程度、社地位、國籍、個人外、種族、宗教信仰、或性向。
為了促進一個開放和歡迎的環境,我們為貢獻者維護者承諾,使我們的項目和社區的參與對每個人來說都是一個無騷擾的體驗,不論年齡、體型、殘疾、種族、性別特徵、性別認同和表達、經驗水平、教育程度、社會經濟地位、國籍、個人外、種族、宗教或性向。
## 我們的準
## 我們的
有助於創造正面環境的行為包括:
有助於創造積極環境的行為示例包括:
- 使用友善和包容的語言
- 尊重不同的觀點經驗
- 優雅地接受建設性批評
- 著重於對社最有利的事情
- 對其他社成員展現同理心
- 使用歡迎和包容的語言
- 尊重不同的觀點經驗
- 優雅地接受建設性批評
- 專注於對社最有利的事情
- 對其他社成員表示同情
參與者不可接受的行為包括:
參與者不可接受的行為示例包括:
- 使用帶有性暗示的言語或影像,以及不受歡迎的性關注或騷擾
- 挑釁、羞辱/貶低他人的評論,以及人身或政治攻擊
- 公開或私下騷擾行為
- 未經他人明確許可,公開他人的私人資料,如實體或電子郵件地址
- 其他在專業環境中可被合理認為不當的行為
- 使用性化語言或圖像以及不受歡迎的性注意或挑逗
- 騷擾、侮辱/貶低性評論和個人或政治攻擊
- 公開或私下騷擾
- 未經明確許可發布他人的私人信息,例如物理或電子地址
- 其他在專業環境中合理認為不當的行為
## 我們的責任
專案維護者有責任清可接受行為的標準,並對任何不可接受行為採取適當公平的糾正措施
項目維護者有責任清可接受行為的標準,並預期對任何不可接受行為的實例採取適當公平的糾正行動
專案維護者有權利和責任除、編輯或拒絕不符合本行為準則的評論、提交、程式碼、維基編輯、題和其他貢獻,或暫時或永久封鎖任何他們認為不當、威脅、冒犯或有害行為的貢獻者。
項目維護者有權利和責任除、編輯或拒絕本行為準則不符的評論、提交、碼、維基編輯、題和其他貢獻,或暫時或永久禁止任何他們認為不當、威脅、冒犯或有害的貢獻者。
## 範
## 範
行為準則適用於專案空間及公開場合,當個人代表本專案或其社群時都必須遵守。代表本專案或社群的情況包括使用官方專案電子郵件地址、過官方社媒體帳號發文,或在線上或實體活動中擔任指定代表。專案維護者進一步定義並釐清專案代表的其他情況
行為準則適用於項目空間內以及當個人代表項目或其社區時的公共空間。代表項目或社區的示例包括使用官方項目電子郵件地址、過官方社媒體帳戶發布或作為在線或離線活動的指定代表。項目的代表可能由項目維護者進一步定義和澄清
## 執行
如發生辱罵、騷擾或其他不可接受行為,請透過 hi@cline.bot 聯絡專案團隊回報。所有申訴都將被審查和調查,並做出必要且合適的回應。專案團隊有義務事件回報者保密。具體執行政策的更多細節可能另行公佈
濫用、騷擾或其他不可接受行為的實例可以通過聯繫項目團隊 hi@cline.bot 來報告。所有投訴將被審查和調查,並將根據情況作出必要和適當的回應。項目團隊有義務事件的報告者保密。具體執行政策的詳細信息可能會單獨發布
遵守或未切實執行行為準則的專案維護者可能會面臨由專案領導團隊其他成員決定的暫時或永久的處置
能善意遵循或執行行為準則的項目維護者可能會面臨由項目領導層其他成員決定的暫時或永久後果
## 來源說明
## 歸屬
行為準則改編自[貢獻者公約][homepage] 1.4,可在此查閱:
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
行為準則改編自 [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
有關此行為準則的常見問題的答案,請參見 https://www.contributor-covenant.org/faq
+54 -57
View File
@@ -1,85 +1,82 @@
# 貢獻 Cline
# 貢獻 Cline
我們非常感謝您有意願貢獻至 Cline。無論是修正程式錯誤、新增功能或改善文件,每一貢獻都讓 Cline 更加出色!為了維持社群的活力與友善,所有成員必須遵守我們的[行為準則](CODE_OF_CONDUCT.md)。
我們很高興您有興趣為 Cline 做出貢獻。無論是修復錯誤、添加功能還是改進我們的文檔,每一貢獻都讓 Cline 更加智能!為了保持我們的社區充滿活力和歡迎,所有成員必須遵守我們的[行為準則](CODE_OF_CONDUCT.md)。
## 回報程式錯誤或問題
## 報告錯誤或問題
程式錯誤回報能幫助 Cline 變得更好!在建立新的議題之前,請[現有](https://github.com/cline/cline/issues)避免重複。當您準備好回報程式錯誤時,請前往我們的[題頁面](https://github.com/cline/cline/issues/new/choose),您會找到協助填寫相關資訊的範本
錯誤報告有助於讓 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>。
🔐 <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)的題。這些議題特別適合新手貢獻者我們也非常歡迎您的協助
尋找一個好的首次貢獻?查看標有["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)的貢獻!無論是修正錯字、改現有指南或建立新的教內容,我們都期待能建立一個由社群共同維護的知識庫,助每個人充分用 Cline。您可以從 `/docs` 開始,尋找需要改善的地方
我們也歡迎對我們[](https://github.com/cline/cline/tree/main/docs)的貢獻!無論是修正錯字、改現有指南還是創建新的教內容 - 我們希望建立一個由社區驅動的資源庫,助每個人充分用 Cline。您可以從深入研究 `/docs` 尋找需要改進的領域開始
若您計畫處理較大的功能,請先建一個[功能請求](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop),以便我們討論該功能是否符合 Cline 的願景。
如果您計劃開發一個更大的功能,請先建一個[功能請求](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 會提示您安裝建議的擴充套件
- 這些擴充套件是開發所需,請接受所有安裝提示
- 若您已關閉提示,可從擴充套件面板手動安裝
1. **VS Code 擴**
2. **本機開發**
- 執行 `npm run install:all` 安裝相依套件
- 執行 `npm run test` 在本機執行測試
- 提交 PR 前,執行 `npm run format:fix` 格式化您的程式碼
- 打開項目時,VS Code 會提示您安裝推薦的擴展
- 這些擴展是開發所需的 - 請接受所有安裝提示
- 如果您忽略了提示,可以從擴展面板手動安裝它們
## 撰寫與提交程式碼
2. **本地開發**
- 運行 `npm run install:all` 安裝依賴項
- 運行 `npm run test` 本地運行測試
- 提交 PR 之前,運行 `npm run format:fix` 格式化您的代碼
任何人都可以貢獻程式碼至 Cline,但我們要求您遵守以下指引,以確保您的貢獻能順利整合:
## 編寫和提交代碼
1. **保持 Pull Request 聚焦**
- 每個 PR 限制在單一功能或錯誤修正
- 將較大的變更拆分成較小且相關的 PR
- 將變更拆分成邏輯性的提交,以便獨立審查
任何人都可以為 Cline 貢獻代碼,但我們要求您遵循以下指南,以確保您的貢獻能夠順利集成:
2. **程式碼品質**
- 執行 `npm run lint` 檢查程式碼風格
- 執行 `npm run format` 自動格式化程式碼
- 所有 PR 必須通過包含程式碼風格檢查與格式化的 CI 檢查
1. **保持 Pull Requests 集中**
- 將 PR 限制在單個功能或錯誤修復
- 將較大的更改拆分為較小的相關 PR
- 將更改分為邏輯提交,可以獨立審查
2. **代碼質量**
- 運行 `npm run lint` 檢查代碼風格
- 運行 `npm run format` 自動格式化代碼
- 所有 PR 必須通過包括 lint 和格式化在內的 CI 檢查
- 提交前解決所有 ESLint 警告或錯誤
- 遵循 TypeScript 最佳實務並維持型別安全
- 遵循 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 訊息,說明影響
- 僅文件變更不需建立 changeset
- 為新功能添加測試
- 運行 `npm test` 確保所有測試通過
- 如果您的更改影響現有測試,請更新它們
- 在適當的地方包括單元測試和集成測試
5. **提交指**
- 撰寫清晰且描述性的提交訊息
- 使用慣用提交格式(例如:「feat:」、「fix:」、「docs:」)
- 在提交中引用相關議題,使用 #issue-number
4. **提交指**
6. **提交前檢查**
- 將您的分支 rebase 到最新的 main
- 確保您的分支可以成功建置
- 再次確認所有測試通過
- 檢查您的變更是否包含除錯程式碼或 console 紀錄
- 撰寫清晰、描述性的提交消息
- 使用常規提交格式(例如 "feat:"、"fix:"、"docs:"
- 在提交中引用相關問題,使用 #issue-number
7. **Pull Request 說明**
- 清楚描述您的變更內容
- 包含測試變更的步驟
- 列出任何重大變更
- 若有使用者介面變更,請附上截圖
5. **提交前**
- 將您的分支重新基於最新的 main
- 確保您的分支成功構建
- 仔細檢查所有測試是否通過
- 檢查您的更改是否有任何調試代碼或控制台日誌
6. **Pull Request 描述**
- 清楚地描述您的更改內容
- 包括測試更改的步驟
- 列出任何重大更改
- 為 UI 更改添加截圖
## 貢獻協議
提交 Pull Request 即表示您同意您的貢獻將依照專案相同的授權條款[Apache 2.0](LICENSE))進行授權
通過提交 pull request您同意您的貢獻將根據與項目相同的許可證[Apache 2.0](LICENSE))進行許可
記住:貢獻 Cline 不只是撰寫程式碼,更是成為塑造 AI 輔助開發未來的社群一份子。讓我們一起打造令人驚艷的成果吧!🚀
記住:貢獻 Cline 不僅僅是編寫代碼 - 這是關於成為一個塑造 AI 輔助開發未來的社區的一部分。讓我們一起創造一些驚人的東西!🚀
+72 -98
View File
@@ -1,8 +1,4 @@
<div align="center"><sub>
<a href="https://github.com/cline/cline/blob/main/README.md" target="_blank">English</a> | <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/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline OpenRouter 第一名的 AI 工具
# Cline OpenRouter 上的 \#1
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
@@ -12,7 +8,7 @@
<table>
<tbody>
<td align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong> VS Marketplace 下載</strong></a>
<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>
@@ -21,29 +17,29 @@
<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>
<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://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>新手上路</strong></a>
<a href="https://cline.bot/join-us" target="_blank"><strong>我們正在招聘!</strong></a>
</td>
</tbody>
</table>
</div>
認識 Cline,一個可以使用您的**命令列介面** (CLI) 和**程式編輯器** (Editor) 的 AI 助
認識 Cline,一個可以使用你的 **CLI****編輯器** 的 AI 助
感謝 [Claude 3.7 Sonnet 的代理式程式設計能力](https://www.anthropic.com/claude/sonnet)Cline 能夠逐步處理複雜的軟開發任務。透過能讓他建立和編輯檔案、探索大型專案、使用瀏覽器,以及執行終端機指令(在您授權後)的工具,他能以超越程式碼自動完成或技術支援的方式協助您。Cline 甚至能使用模型上下文協定(Model Context ProtocolMCP)來建立新工具並擴展自己的能。雖然自主 AI 腳本傳統上在沙環境中行,但這個擴充套件提供了人機互動的圖形介面,讓您可以核准每個檔案變更和終端機指令,提供一個安全且容易使用的方式來探索代理 AI 的潛力。
感謝 [Claude 3.7 Sonnet 的代理編碼能力](https://www.anthropic.com/claude/sonnet)Cline 可以一步步處理複雜的軟開發任務。通過允許他創建和編輯文件、探索大型項目、使用瀏覽器執行終端令(在你授予權限後),他可以提供超越代碼完成或技術支持的幫助。Cline 甚至可以使用 Model Context Protocol (MCP) 創建新工具並擴展自己的能。雖然自主 AI 腳本傳統上在沙環境中行,但此擴展提供了一個人機交互的 GUI 來批准每個文件更改和終端令,提供了一種安全且可訪問的方式來探索代理 AI 的潛力。
1. 輸入的任務,並可以加入圖片來將設計稿轉換功能應用程式,或使用截圖來修正錯誤。
2. Cline 先分析您的檔案結構和程式碼 AST、執行正表達式搜,並讀取相關檔案,以便在現有專案中快速掌握狀況。透過仔細管理加入上下文的資訊,Cline 可以在不超過上下文視窗的情況下,為大型複雜的專案提供有價值的協助
3. 一旦 Cline 得所需資訊後,他可以:
-和編輯檔案,並在過程中監控程式碼檢查工具/編譯器錯誤,讓他能主動修正缺少的匯入語句和語法錯誤等問題。
- 直接在的終端中執行令並監控其輸出,讓他能夠在編輯檔案後回應開發伺服器的問題
- 對於網頁開發任務,Cline 可以在無頭瀏覽器中啟動網站、點選、輸入、動並擷取螢幕截圖和主控台記錄,讓他能修正執行時錯誤和視覺問題
4. 當任務完成時,Cline 會以終端機指令(`open -a "Google Chrome" index.html`)向您呈現結果,您只需點選按鈕即可執行
1. 輸入的任務並添加圖像,將模型轉換功能應用程序或通過截圖修復錯誤。
2. Cline 先分析你的文件結構和源代碼 AST,運行正表達式搜,並閱讀相關文件以了解現有項目。通過仔細管理添加到上下文中的信息,Cline 即使在大型複雜項目中也能提供有價值的幫助,而不會使上下文窗口過載
3. 一旦 Cline 得所需信息,他可以:
- 建和編輯文件 + 監控 linter/編譯器錯誤,從而主動修復諸如缺少導入和語法錯誤等問題。
- 直接在的終端中執行令並監控其輸出,從而在編輯文件後對開發服務器問題做出反應
- 對於 Web 開發任務,Cline 可以在無頭瀏覽器中啟動網站,點擊、輸入、動並捕獲截圖和控制台日誌,從而修復運行時錯誤和視覺錯誤
4. 當任務完成時,Cline 將通過終端命令`open -a "Google Chrome" index.html` 向你展示結果,你可以通過點擊按鈕運行該命令
> [!TIP]
> 使用 `CMD/CTRL + Shift + P` 快速鍵開啟命令選擇區,輸入「Cline: Open In New Tab」即可在編輯器中以分頁方式開啟擴充套件。這讓可以同時檢視檔案總管,並更清楚地看到 Cline 如何變更您的工作
> [!提示]
> 使用 `CMD/CTRL + Shift + P` 快捷鍵打開命令面板並輸入 "Cline: Open In New Tab" 將擴展作為標籤在編輯器中打開。這讓可以與文件資源管理器並排使用 Cline,更清楚地看到他如何改變你的工作空間
---
@@ -51,137 +47,115 @@
### 使用任何 API 和模型
Cline 支 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供者。您也可以設定任何與 OpenAI 相容的 API,或過 LM Studio/Ollama 使用本模型。若您使用 OpenRouter此擴充套件會擷取他們最新模型列表,讓您能在新模型推出時立即使用。
Cline 支 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供商。你還可以配置任何兼容 OpenAI 的 API,或過 LM Studio/Ollama 使用本模型。如果你使用 OpenRouter擴展會獲取他們最新模型列表,讓在新模型可用時立即使用。
此擴充套件也會追蹤整個任務迴圈和個別請求的 token 總數和 API 使用成本,讓您隨時掌握費用支出
擴展還會跟蹤整個任務循環和單個請求的總令牌和 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 可以直接在的終端中執行令並接收輸出。這他能執行各種任務,從安裝套件和執行建置腳本到部署應用程、管理資料庫和執行測試,同時適應的開發環境和工具鏈以正確完成工作。
感謝 VSCode v1.93 中的新 [終端 shell 集成更新](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api)Cline 可以直接在的終端中執行令並接收輸出。這使他能執行廣泛的任務,從安裝包和運行構建腳本到部署應用程、管理數據庫和執行測試,同時適應的開發環境和工具鏈以正確完成工作。
對於開發伺服器等長時間行的程序,使用「繼續執行中的程序」按鈕讓 Cline 在指令於背景執行時繼續任務。當 Cline 工作時,他會收到任何新的終端輸出通知,讓他能回應可能出現的問題,例如編輯檔案時的編譯錯誤。
對於長時間行的進程如開發服務器,使用“在運行時繼續”按鈕讓 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 可以直接在的編輯器中建和編輯檔案,並顯示變更的差異檢視。您可以直接在差異視編輯器中編輯或還原 Cline 的更,或在聊天中提供意見回饋,直到您滿意結果為止。Cline 會監控程式碼檢查工具/編譯器錯誤(缺少的匯入語句、語法錯誤等),讓他能自行修正過程中出現的問題。
Cline 可以直接在的編輯器中建和編輯文件,向你展示更改的差異視圖。你可以直接在差異視編輯器中編輯或恢復 Cline 的更,或在聊天中提供饋,直到你對結果滿意。Cline 會監控 linter/編譯器錯誤(缺少導入、語法錯誤等),以便他在過程中自行修復出現的問題。
所有 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 可以啟動瀏覽器、點選元素輸入文字和捲動,在每個步驟擷取螢幕截圖和主控台記錄。這讓互動式除錯、端端測試,甚至一般網頁使用成為可能!這他能獨立修正視覺問題和執行時錯誤,而不需要您手動複製錯誤記錄
借助 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)
試讓 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">
### 「新增一個工具來...」
### “添加一個工具……”
感謝[模型上下文協定](https://github.com/modelcontextprotocol)Cline 可以過自工具擴展他的能。雖然可以使用[製作的服器](https://github.com/modelcontextprotocol/servers),但 Cline 可以改為建立專門為您的工作流程量身打造的工具。只要請 Cline 「新增工具,他就會處理所有事情,從建新的 MCP 服器到將其安裝到擴充套件中。這些自訂工具就會成為 Cline 工具的一部分,隨時可用於未來的任務。
感謝 [Model Context Protocol](https://github.com/modelcontextprotocol)Cline 可以過自定義工具擴展他的能。雖然可以使用 [製作的服](https://github.com/modelcontextprotocol/servers),但 Cline 可以創建和安裝適合你特定工作流程的工具。只需讓 Cline “添加一個工具,他處理所有事情,從建新的 MCP 服器到將其安裝到擴中。這些自定義工具將成為 Cline 工具的一部分,準備在未來的任務中使用
- 「新增一個取 Jira 工單的工具」:取得工單驗收條件並讓 Cline 開始工作
- 「新增一個管理 AWS EC2 的工具:檢查服器指標並調整執行個體規模
- 「新增一個取最新 PagerDuty 事件的工具」:取得詳細資訊並請 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`**貼上網址讓擴充套件擷取並轉換為 Markdown,當想給 Cline 最新文件時很有用
**`@problems`:**新增工作區的錯誤和警告(「問題」面板)給 Cline 修正
**`@file`:**新增檔案內容,讓您不必浪費 API 請求來核准讀取檔案(+ 輸入以搜尋檔案)
**`@folder`:**一次新增整個資料夾的檔案,讓您的工作流程更快速
**`@url`** 粘貼一個 URL 以供擴展獲取並轉換為 markdown,當想給 Cline 提供最新文檔時非常有用
**`@problems`:** 添加工作區錯誤和警告(“問題”面板)以供 Cline 修復
**`@file`:** 添加文件內容,這樣你就不必浪費 API 請求批准讀取文件(+ 輸入以搜索文件)
**`@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 處理任務時,擴充套件會在每個步驟擷取您工作區快照。可以使用比較按鈕檢視快照與目前工作區的差異,並使用「還原」按鈕回到該時間點。
當 Cline 完成任務時,擴展會在每一步拍攝你的工作區快照。可以使用比較按鈕查看快照和當前工作區之間的差異,並使用“恢復”按鈕回到該點。
例如,使用本機網頁伺服器時,可以使用「僅還原工作區」來快速測試應用程的不同版本,然後在找到要繼續開發的版本時使用「還原任務和工作區。這讓您能安全地探索不同方法而不會失進度。
例如,使用本地 Web 服務器時,可以使用“僅恢復工作區快速測試應用程的不同版本,然後在找到要繼續構建的版本時使用“恢復任務和工作區。這讓你可以安全地探索不同方法而不會失進度。
<!-- 透明像素以在浮動圖像後創建換行 -->
<!-- 透明像素用於浮動圖片後的換行 -->
<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)上的職缺
要為項目做出貢獻,請我們的 [貢獻指南](CONTRIBUTING.md) 開始,了解基礎知識。你還可以加入我們的 [Discord](https://discord.gg/cline) `#contributors` 頻道與其他貢獻者聊天。如果你正在尋找全職工作,請查看我們在 [招聘頁面](https://cline.bot/join-us) 上的開放職位
<details>
<summary>本開發說明</summary>
<summary>本開發說明</summary>
1. 複製程式碼庫(需要 [git-lfs](https://git-lfs.com/)
```bash
git clone https://github.com/cline/cline.git
```
2. 在 VSCode 中開啟專案:
```bash
code cline
```
3. 安裝擴充套件和網頁介面所需的相依套件:
```bash
npm run install:all
```
4. 按下 `F5`(或選擇「執行」->「開始除錯」)來啟動並開啟一個已載入擴充套件的新 VSCode 視窗。(如果建置專案時遇到問題,您可能需要安裝 [esbuild problem matchers 擴充套件](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)
1. 克隆倉庫 _(需要 [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`(或 `運行`->`開始調試`)啟動以打開一個加載了擴展的新 VSCode 窗口。(如果你在構建項目時遇到問題,可能需要安裝 [esbuild problem matchers 擴展](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` 檔案
3. 推送您的分支並在 GitHub 上建立 PR。我們的 CI 會:
- 執行測試和檢查
- Changesetbot 會建立一個顯示版本影響的評論
- 當合併到 main 時,changesetbot 會建立一個 Version Packages PR
- 當 Version Packages PR 合併時,就會發布新版本
</details>
## 授權條款
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+286 -4525
View File
File diff suppressed because it is too large Load Diff
+16 -103
View File
@@ -2,8 +2,12 @@
"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.9.0",
"version": "3.4.9",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",
"theme": "dark"
},
"engines": {
"vscode": "^1.84.0"
},
@@ -69,7 +73,7 @@
{
"command": "cline.mcpButtonClicked",
"title": "MCP Servers",
"icon": "$(server)"
"icon": "$(extensions)"
},
{
"command": "cline.historyButtonClicked",
@@ -81,11 +85,6 @@
"title": "Open in Editor",
"icon": "$(link-external)"
},
{
"command": "cline.accountButtonClicked",
"title": "Account",
"icon": "$(account)"
},
{
"command": "cline.settingsButtonClicked",
"title": "Settings",
@@ -95,27 +94,6 @@
"command": "cline.openInNewTab",
"title": "Open In New Tab",
"category": "Cline"
},
{
"command": "cline.dev.createTestTasks",
"title": "Create Test Tasks",
"category": "Cline",
"when": "cline.isDevMode"
},
{
"command": "cline.addToChat",
"title": "Add to Cline",
"category": "Cline"
},
{
"command": "cline.addTerminalOutputToChat",
"title": "Add to Cline",
"category": "Cline"
},
{
"command": "cline.fixWithCline",
"title": "Fix with Cline",
"category": "Cline"
}
],
"menus": {
@@ -141,59 +119,9 @@
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.accountButtonClicked",
"command": "cline.settingsButtonClicked",
"group": "navigation@5",
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.settingsButtonClicked",
"group": "navigation@6",
"when": "view == claude-dev.SidebarProvider"
}
],
"editor/title": [
{
"command": "cline.plusButtonClicked",
"group": "navigation@1",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.mcpButtonClicked",
"group": "navigation@2",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.historyButtonClicked",
"group": "navigation@3",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.popoutButtonClicked",
"group": "navigation@4",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.accountButtonClicked",
"group": "navigation@5",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.settingsButtonClicked",
"group": "navigation@6",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
}
],
"editor/context": [
{
"command": "cline.addToChat",
"group": "navigation",
"when": "editorHasSelection"
}
],
"terminal/context": [
{
"command": "cline.addTerminalOutputToChat",
"group": "navigation"
}
]
},
@@ -232,7 +160,7 @@
"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."
"description": "Enables extension to save checkpoints of workspace throughout the task."
},
"cline.disableBrowserTool": {
"type": "boolean",
@@ -298,13 +226,10 @@
"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",
"test:ci": "node scripts/test-ci.js",
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
"test:coverage": "vscode-test --coverage",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
@@ -318,15 +243,10 @@
"devDependencies": {
"@changesets/cli": "^2.27.12",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
"@types/diff": "^5.2.1",
"@types/get-folder-size": "^3.0.4",
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/turndown": "^5.0.5",
"@types/vscode": "^1.84.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
@@ -339,31 +259,26 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"should": "^13.2.3",
"sinon": "^19.0.2",
"ts-node": "^10.9.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",
"@opentelemetry/api": "^1.4.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.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",
"default-shell": "^2.2.0",
"delay": "^6.0.0",
"diff": "^5.2.0",
"execa": "^9.5.2",
"fast-deep-equal": "^3.1.3",
@@ -374,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",
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env node
const { execSync } = require("child_process")
const process = require("process")
try {
if (process.platform === "linux") {
console.log("Detected Linux environment.")
execSync("which xvfb-run", { stdio: "ignore" })
console.log("xvfb-run is installed. Running tests with xvfb-run...")
execSync("xvfb-run -a npm run test", { stdio: "inherit" })
} else {
console.log("Non-Linux environment detected. Running tests normally.")
execSync("npm run test", { stdio: "inherit" })
}
} catch (error) {
if (process.platform === "linux") {
console.error(
`Error: xvfb-run is not installed.\n` +
`Please install it using the following command:\n` +
` Debian/Ubuntu: sudo apt install xvfb\n` +
` RHEL/CentOS: sudo yum install xvfb\n` +
` Arch Linux: sudo pacman -S xvfb`,
)
} else {
console.error("Error running tests:", error.message)
}
process.exit(1)
}
+1 -14
View File
@@ -9,23 +9,18 @@ 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 +60,8 @@ 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)
}
+1 -32
View File
@@ -22,10 +22,6 @@ export class AnthropicHandler implements ApiHandler {
const model = this.getModel()
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
const modelId = model.id
const 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":
@@ -45,11 +41,8 @@ export class AnthropicHandler implements ApiHandler {
stream = await this.client.messages.create(
{
model: modelId,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : 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: 0,
system: [
{
text: systemPrompt,
@@ -155,20 +148,6 @@ export class AnthropicHandler implements ApiHandler {
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":
// we may receive multiple text blocks, in which case just insert a line break between them
if (chunk.index > 0) {
@@ -186,22 +165,12 @@ export class AnthropicHandler 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",
text: chunk.delta.text,
}
break
case "signature_delta":
// We don't need to do anything with the signature in the client
// It's used when sending the thinking block back to the API
break
}
break
case "content_block_stop":
-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],
}
}
}
+23 -532
View File
@@ -1,18 +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,
ConversationRole,
ConverseStreamCommand,
InvokeModelWithResponseStreamCommand,
} from "@aws-sdk/client-bedrock-runtime"
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
export class AwsBedrockHandler implements ApiHandler {
@@ -22,82 +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
const modelId = await this.getModelId()
const model = this.getModel()
let modelId = await this.getModelId()
// Check if this is an Amazon Nova model
if (modelId.includes("amazon.nova")) {
yield* this.createNovaMessage(systemPrompt, messages, modelId, model)
return
}
// Check if this is a Deepseek model
if (modelId.includes("deepseek")) {
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
return
}
const 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":
@@ -106,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":
@@ -117,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 {
@@ -149,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",
@@ -179,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)
@@ -203,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) {
const 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
@@ -277,388 +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)
}
/**
* Creates a message using Amazon Nova models through AWS Bedrock
* Implements support for Nova Micro, Nova Lite, and Nova Pro models
*/
private async *createNovaMessage(
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 messages for Nova model
const formattedMessages = this.formatNovaMessages(messages)
// Prepare request for Nova model
const command = new ConverseStreamCommand({
modelId: modelId,
messages: formattedMessages,
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
inferenceConfig: {
maxTokens: model.info.maxTokens || 5000,
temperature: 0,
// topP: 0.9, // Alternative: use topP instead of temperature
},
})
// Execute the streaming request and handle response
try {
const response = await client.send(command)
if (response.stream) {
let hasReportedInputTokens = false
for await (const chunk of response.stream) {
// Handle metadata events with token usage information
if (chunk.metadata?.usage) {
// Report complete token usage from the model itself
const inputTokens = chunk.metadata.usage.inputTokens || 0
const outputTokens = chunk.metadata.usage.outputTokens || 0
yield {
type: "usage",
inputTokens,
outputTokens,
totalCost: calculateApiCostOpenAI(model.info, inputTokens, outputTokens, 0, 0),
}
hasReportedInputTokens = true
}
// Handle content delta (text generation)
if (chunk.contentBlockDelta?.delta?.text) {
yield {
type: "text",
text: chunk.contentBlockDelta.delta.text,
}
}
// Handle reasoning content if present
if (chunk.contentBlockDelta?.delta?.reasoningContent?.text) {
yield {
type: "reasoning",
reasoning: chunk.contentBlockDelta.delta.reasoningContent.text,
}
}
// Handle errors
if (chunk.internalServerException) {
yield {
type: "text",
text: `[ERROR] Internal server error: ${chunk.internalServerException.message}`,
}
} else if (chunk.modelStreamErrorException) {
yield {
type: "text",
text: `[ERROR] Model stream error: ${chunk.modelStreamErrorException.message}`,
}
} else if (chunk.validationException) {
yield {
type: "text",
text: `[ERROR] Validation error: ${chunk.validationException.message}`,
}
} else if (chunk.throttlingException) {
yield {
type: "text",
text: `[ERROR] Throttling error: ${chunk.throttlingException.message}`,
}
} else if (chunk.serviceUnavailableException) {
yield {
type: "text",
text: `[ERROR] Service unavailable: ${chunk.serviceUnavailableException.message}`,
}
}
}
}
} catch (error) {
console.error("Error processing Nova model response:", error)
yield {
type: "text",
text: `[ERROR] Failed to process Nova response: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Formats messages for Amazon Nova models according to the SDK specification
*/
private formatNovaMessages(messages: Anthropic.Messages.MessageParam[]): { role: ConversationRole; content: any[] }[] {
return messages.map((message) => {
// Determine role (user or assistant)
const role = message.role === "user" ? ConversationRole.USER : ConversationRole.ASSISTANT
// Process content based on type
let content: any[] = []
if (typeof message.content === "string") {
// Simple text content
content = [{ text: message.content }]
} else if (Array.isArray(message.content)) {
// Convert Anthropic content format to Nova content format
content = message.content
.map((item) => {
// Text content
if (item.type === "text") {
return { text: item.text }
}
// Image content
if (item.type === "image") {
// Handle different image source formats
let imageData: Uint8Array
let format = "jpeg" // default format
// Extract format from media_type if available
if (item.source.media_type) {
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
if (formatMatch && formatMatch[1]) {
format = formatMatch[1]
// Ensure format is one of the allowed values
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
format = "jpeg" // Default to jpeg if not supported
}
}
}
// Get image data
try {
if (typeof item.source.data === "string") {
// Handle base64 encoded data
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
} else if (item.source.data && typeof item.source.data === "object") {
// Try to convert to Uint8Array
imageData = new Uint8Array(Buffer.from(item.source.data as any))
} else {
console.error("Unsupported image data format")
return null // Skip this item if format is not supported
}
} catch (error) {
console.error("Could not convert image data to Uint8Array:", error)
return null // Skip this item if conversion fails
}
return {
image: {
format,
source: {
bytes: imageData,
},
},
}
}
// Return null for unsupported content types
return null
})
.filter(Boolean) // Remove any null items
}
// Return formatted message
return {
role,
content,
}
})
}
}
-128
View File
@@ -1,128 +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 || "",
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on cline.bot rankings.
"X-Title": "Cline", // Optional. Shows in rankings on cline.bot.
"X-Task-ID": this.options.taskId || "", // Include the task ID in the request headers
},
})
}
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,
this.options.openRouterProviderSorting,
)
let didOutputUsage: boolean = false
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
}
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,
}
}
if (!didOutputUsage && chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: chunk.usage.cost || 0,
}
didOutputUsage = true
}
}
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
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 }
}
}
+2 -3
View File
@@ -36,15 +36,14 @@ export class DeepSeekHandler implements ApiHandler {
}
const deepUsage = usage as DeepSeekUsage
const inputTokens = deepUsage?.prompt_tokens || 0 // sum of cache hits and misses
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)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens) // this will always be 0
yield {
type: "usage",
inputTokens: nonCachedInputTokens,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
+1 -71
View File
@@ -17,76 +17,23 @@ export class LiteLlmHandler implements ApiHandler {
})
}
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
try {
const response = await fetch(`${this.client.baseURL}/spend/calculate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.options.liteLlmApiKey}`,
},
body: JSON.stringify({
completion_response: {
model: modelId,
usage: {
prompt_tokens,
completion_tokens,
},
},
}),
})
if (response.ok) {
const data: { cost: number } = await response.json()
return data.cost
} else {
console.error("Error calculating spend:", response.statusText)
return undefined
}
} catch (error) {
console.error("Error calculating spend:", error)
return undefined
}
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const formattedMessages = convertToOpenAiMessages(messages)
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
content: systemPrompt,
}
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
// Configuration for extended thinking
const budgetTokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = budgetTokens !== 0 ? true : false
const thinkingConfig = reasoningOn ? { type: "enabled", budget_tokens: budgetTokens } : undefined
let temperature: number | undefined = 0
if (isOminiModel && reasoningOn) {
temperature = undefined // Thinking mode doesn't 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 },
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
})
const inputCost = (await this.calculateCost(1e6, 0)) || 0
const outputCost = (await this.calculateCost(0, 1e6)) || 0
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
// Handle normal text content
if (delta?.content) {
yield {
type: "text",
@@ -94,28 +41,11 @@ export class LiteLlmHandler implements ApiHandler {
}
}
// Handle reasoning events (thinking)
// Thinking is not in the standard types but may be in the response
interface ThinkingDelta {
thinking?: string
}
if ((delta as ThinkingDelta)?.thinking) {
yield {
type: "reasoning",
reasoning: (delta as ThinkingDelta).thinking || "",
}
}
// Handle token usage information
if (chunk.usage) {
const totalCost =
(inputCost * chunk.usage.prompt_tokens) / 1e6 + (outputCost * chunk.usage.completion_tokens) / 1e6
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
totalCost,
}
}
}
+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,
}
}
}
+2 -3
View File
@@ -26,15 +26,14 @@ export class OpenAiNativeHandler implements ApiHandler {
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
const inputTokens = usage?.prompt_tokens || 0 // sum of cache hits and misses
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)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
yield {
type: "usage",
inputTokens: nonCachedInputTokens,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
+84 -45
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,
@@ -30,69 +28,110 @@ export class OpenAiHandler implements ApiHandler {
}
}
private async diagnoseRequestProblem(
modelId: string,
messages: OpenAI.Chat.ChatCompletionMessageParam[],
apiKey: string,
baseURL: string,
) {
const url = `${baseURL}/chat/completions`
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: messages,
temperature: 0,
stream: true,
}),
})
if (!response.ok) {
return `HTTP error! status: ${response.status}, statusText: ${response.statusText}`
}
const responseData = await response.json()
return responseData
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
private async *handleChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): ApiStream {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.openAiModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
const isR1FormatRequired = this.options.openAiModelInfo?.isR1FormatRequired ?? false
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
let maxTokens: number | undefined
if (this.options.openAiModelInfo?.maxTokens && this.options.openAiModelInfo.maxTokens > 0) {
maxTokens = Number(this.options.openAiModelInfo.maxTokens)
} else {
maxTokens = undefined
}
if (isDeepseekReasoner || isR1FormatRequired) {
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,
max_tokens: maxTokens,
reasoning_effort: reasoningEffort,
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 (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
const [validationStream, contentStream] = stream.tee()
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
// Check the first chunk to detect potential stream issues early
// This helps to provide better error messages for cases like:
// https://github.com/cline/cline/issues/1662
// where the stream appears valid but contains no actual data
const firstChunk = await validationStream[Symbol.asyncIterator]().next()
if (firstChunk.done || !firstChunk.value) {
// Make an additional request to get detailed error information
// This gives us more context about what went wrong with the API call
const errorResponse = await this.diagnoseRequestProblem(
modelId,
openAiMessages,
this.client.apiKey,
this.client.baseURL,
)
throw new Error(`Stream empty. Error details: ${JSON.stringify(errorResponse)}`)
}
yield* this.handleChunk(firstChunk.value)
for await (const chunk of contentStream) {
yield* this.handleChunk(chunk)
}
}
+152 -49
View File
@@ -1,18 +1,17 @@
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
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,32 +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,
this.options.openRouterProviderSorting,
)
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
let didOutputUsage: boolean = false
// 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
@@ -66,42 +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 (!didOutputUsage && chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: chunk.usage.cost || 0,
}
didOutputUsage = true
// 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,
// }
// }
}
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
await setTimeoutPromise(500) // FIXME: necessary delay to ensure generation endpoint is ready
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,
@@ -115,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 })
@@ -126,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],
}
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { ApiHandler } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@@ -26,7 +27,7 @@ export class RequestyHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.requestyModelId ?? ""
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
-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
}
+43 -235
View File
@@ -4,267 +4,75 @@ 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()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
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
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,
),
}
}
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
}
}),
stream: true,
},
{
headers: {},
},
)
const stream = await this.client.messages.create({
model: this.getModel().id,
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":
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
}
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,
})
case "message_delta":
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
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",
}
}
case "content_block_start":
switch (chunk.content_block.type) {
case "text":
if (chunk.index > 0) {
yield {
type: "text",
text: chunk.content_block.text,
text: "\n",
}
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: "" }
}
}),
},
],
}
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) {
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
}
}
}
-64
View File
@@ -1,64 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, XAIModelId, ModelInfo, xaiDefaultModelId, xaiModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
export class XAIHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.x.ai/v1",
apiKey: this.options.xaiApiKey,
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
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: 0,
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,
}
}
}
}
getModel(): { id: XAIModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in xaiModels) {
const id = modelId as XAIModelId
return { id, info: xaiModels[id] }
}
return {
id: xaiDefaultModelId,
info: xaiModels[xaiDefaultModelId],
}
}
}
+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,
})
}
}
}
+1 -1
View File
@@ -307,7 +307,7 @@ function parseToolCall(toolName: string, content: string): ToolCall | null {
// Parse nested XML elements
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
let match: RegExpExecArray | null
let match
while ((match = paramRegex.exec(innerContent)) !== null) {
const [, paramName, paramValue] = match
-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
const 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
}
+2 -2
View File
@@ -38,7 +38,7 @@ export function convertToOpenAiMessages(
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
let toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
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 OpenAI 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
@@ -127,7 +127,7 @@ export function convertToOpenAiMessages(
}
// Process tool use messages
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
id: toolMessage.id,
type: "function",
function: {
-154
View File
@@ -1,154 +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,
openRouterProviderSorting?: string,
) {
// 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,
stream_options: { include_usage: true },
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
include_reasoning: true,
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
...(reasoning ? { reasoning } : {}),
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
})
return stream
}
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
# Core Architecture
Extension entry point (extension.ts) -> webview -> controller -> task
```tree
core/
├── webview/ # Manages webview lifecycle
├── controller/ # Handles webview messages and task management
├── task/ # Executes API requests and tool operations
└── ... # Additional components to help with context, parsing user/assistant messages, etc.
```
+62 -2
View File
@@ -20,7 +20,7 @@ export const toolUseNames = [
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",
"plan_mode_respond",
"plan_mode_response",
"attempt_completion",
] as const
@@ -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,7 +1,7 @@
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "."
export function parseAssistantMessage(assistantMessage: string) {
const contentBlocks: AssistantMessageContent[] = []
let contentBlocks: AssistantMessageContent[] = []
let currentTextContent: TextContent | undefined = undefined
let currentTextContentStartIndex = 0
let currentToolUse: ToolUse | undefined = undefined
@@ -1,120 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
import { ApiHandler } from "../../api"
import { OpenAiHandler } from "../../api/providers/openai"
export class ContextManager {
getNewContextMessagesAndMetadata(
apiConversationHistory: Anthropic.Messages.MessageParam[],
clineMessages: ClineMessage[],
api: ApiHandler,
conversationHistoryDeletedRange: [number, number] | undefined,
previousApiReqIndex: number,
) {
let updatedConversationHistoryDeletedRange = false
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
let contextWindow = api.getModel().info.contextWindow || 128_000
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
contextWindow = 64_000
}
let maxAllowedSize: number
switch (contextWindow) {
case 64_000: // deepseek models
maxAllowedSize = contextWindow - 27_000
break
case 128_000: // most models
maxAllowedSize = contextWindow - 30_000
break
case 200_000: // claude models
maxAllowedSize = contextWindow - 40_000
break
default:
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
}
// This is the most reliable way to know when we're close to hitting the context window.
if (totalTokens >= maxAllowedSize) {
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange,
keep,
)
updatedConversationHistoryDeletedRange = true
}
}
}
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
return {
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
truncatedConversationHistory: truncatedConversationHistory,
}
}
public getNextTruncationRange(
apiMessages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined,
keep: "half" | "quarter",
): [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 remaining user-assistant pairs
// We first calculate half of the messages then divide by 2 to get the number of pairs.
// After flooring, we multiply by 2 to get the number of messages.
// Note that this will also always be an even number.
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
} else {
// Remove 3/4 of remaining user-assistant pairs
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
// After flooring, we multiply by 2 to get the number of messages.
// Note that this will also always be an even number.
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 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 (apiMessages[rangeEndIndex].role !== "user") {
rangeEndIndex -= 1
}
// this is an inclusive range that will be removed from the conversation history
return [rangeStartIndex, rangeEndIndex]
}
public 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)]
}
}
@@ -1,155 +0,0 @@
import { ContextManager } from "../ContextManager"
import { Anthropic } from "@anthropic-ai/sdk"
import { expect } from "chai"
describe("ContextManager", () => {
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
const messages: Anthropic.Messages.MessageParam[] = []
messages.push({
role: "user",
content: "Initial task message",
})
let role: "user" | "assistant" = "assistant"
for (let i = 1; i < count; i++) {
messages.push({
role,
content: `Message ${i}`,
})
role = role === "user" ? "assistant" : "user"
}
return messages
}
describe("getNextTruncationRange", () => {
let contextManager: ContextManager
beforeEach(() => {
contextManager = new ContextManager()
})
it("first truncation with half keep", () => {
const messages = createMessages(11)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(result).to.deep.equal([1, 4])
})
it("first truncation with quarter keep", () => {
const messages = createMessages(11)
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
expect(result).to.deep.equal([1, 6])
})
it("sequential truncation with half keep", () => {
const messages = createMessages(21)
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(firstRange).to.deep.equal([1, 10])
// Pass the previous range for sequential truncation
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
expect(secondRange).to.deep.equal([1, 14])
})
it("sequential truncation with quarter keep", () => {
const messages = createMessages(41)
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
expect(secondRange[0]).to.equal(1)
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
})
it("ensures the last message in range is a user message", () => {
const messages = createMessages(14)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
// Check if the message at the end of range is a user message
const lastRemovedMessage = messages[result[1]]
expect(lastRemovedMessage.role).to.equal("user")
// Check if the next message after the range is an assistant message
const nextMessage = messages[result[1] + 1]
expect(nextMessage.role).to.equal("assistant")
})
it("handles small message arrays", () => {
const messages = createMessages(3)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(result).to.deep.equal([1, 0])
})
it("preserves the message structure when truncating", () => {
const messages = createMessages(20)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
// Get messages after removing the range
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
// Check first message and alternating pattern
expect(effectiveMessages[0].role).to.equal("user")
for (let i = 1; i < effectiveMessages.length; i++) {
const expectedRole = i % 2 === 1 ? "assistant" : "user"
expect(effectiveMessages[i].role).to.equal(expectedRole)
}
})
})
describe("getTruncatedMessages", () => {
let contextManager: ContextManager
beforeEach(() => {
contextManager = new ContextManager()
})
it("returns original messages when no range is provided", () => {
const messages = createMessages(3)
const result = contextManager.getTruncatedMessages(messages, undefined)
expect(result).to.deep.equal(messages)
})
it("correctly removes messages in the specified range", () => {
const messages = createMessages(5)
const range: [number, number] = [1, 3]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(2)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[4])
})
it("works with a range that starts at the first message after task", () => {
const messages = createMessages(4)
const range: [number, number] = [1, 2]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(2)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[3])
})
it("correctly handles removing a range while preserving alternation pattern", () => {
const messages = createMessages(5)
const range: [number, number] = [1, 2]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(3)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[3])
expect(result[2]).to.deep.equal(messages[4])
expect(result[0].role).to.equal("user")
expect(result[1].role).to.equal("assistant")
expect(result[2].role).to.equal("user")
})
})
})
@@ -1,10 +0,0 @@
export function checkIsOpenRouterContextWindowError(error: any): boolean {
return error.code === 400 && error.message?.includes("context length")
}
export function checkIsAnthropicContextWindowError(response: any): boolean {
return (
response?.error?.error?.type === "invalid_request_error" &&
response?.error?.error?.message?.includes("prompt is too long")
)
}
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -72,10 +72,7 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
}
}
// Filter out duplicate mentions while preserving order
const uniqueMentions = Array.from(new Set(mentions))
for (const mention of uniqueMentions) {
for (const mention of mentions) {
if (mention.startsWith("http")) {
let result: string
if (launchBrowserError) {
-82
View File
@@ -117,88 +117,6 @@ Otherwise, if you have not completed the task and do not need additional informa
const prettyPatchLines = lines.slice(4)
return prettyPatchLines.join("\n")
},
taskResumption: (
mode: "plan" | "act",
agoText: string,
cwd: string,
wasRecent: boolean | 0 | undefined,
responseText?: string,
) => {
return `[TASK RESUMPTION] ${
mode === "plan"
? `This task was interrupted ${agoText}. The conversation may have been incomplete. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful. However you are in PLAN MODE, so rather than continuing the task, you must respond to the user's message.`
: `This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.`
}${
wasRecent
? "\n\nIMPORTANT: If the last tool use was a replace_in_file or write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
: ""
}${
responseText
? `\n\n${mode === "plan" ? "New message to respond to with plan_mode_respond tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
: mode === "plan"
? "(The user did not provide a new message. Consider asking them how they'd like you to proceed, or to switch to Act mode to continue with the task.)"
: ""
}`
},
planModeInstructions: () => {
return `In this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_respond tool to engage in a conversational back and forth with the user. Do not use the plan_mode_respond tool until you've gathered all the information you need e.g. with read_file or ask_followup_question.
(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)`
},
fileEditWithUserChanges: (
relPath: string,
userEdits: string,
autoFormattingEdits: string | undefined,
finalContent: string | undefined,
newProblemsMessage: string | undefined,
) =>
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
(autoFormattingEdits
? `The user's editor also applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
: "") +
`The updated content, which includes both your original modifications and the additional edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file that was saved:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
`Please note:\n` +
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
`2. Proceed with the task using this updated file content as the new baseline.\n` +
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
`4. IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including both user edits and any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n` +
`${newProblemsMessage}`,
fileEditWithoutUserChanges: (
relPath: string,
autoFormattingEdits: string | undefined,
finalContent: string | undefined,
newProblemsMessage: string | undefined,
) =>
`The content was successfully saved to ${relPath.toPosix()}.\n\n` +
(autoFormattingEdits
? `Along with your edits, the user's editor applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
: "") +
`Here is the full, updated content of the file that was saved:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
`IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n\n` +
`${newProblemsMessage}`,
diffError: (relPath: string, originalContent: string | undefined) =>
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file.\n\n` +
`The file was reverted to its original state:\n\n` +
`<file_content path="${relPath.toPosix()}">\n${originalContent}\n</file_content>\n\n` +
`Now that you have the latest state of the file, try the operation again with fewer/more precise SEARCH blocks.\n(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback. Keep in mind, the write_to_file fallback is far from ideal, as this means you'll be re-writing the entire contents of the file just to make a few edits, which takes time and money. So let's bias towards using replace_in_file as effectively as possible)`,
toolAlreadyUsed: (toolName: string) =>
`Tool [${toolName}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
clineIgnoreInstructions: (content: string) =>
`# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${content}\n.clineignore`,
clineRulesDirectoryInstructions: (cwd: string, content: string) =>
`# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
clineRulesFileInstructions: (cwd: string, content: string) =>
`# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
}
// to avoid circular dependency
+10 -18
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. IMPORTANT: NEVER include an option to toggle to Act mode, as this would be something you need to direct the user to do manually themselves if needed.
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
@@ -239,18 +235,14 @@ Your final result description here
<command>Command to demonstrate result (optional)</command>
</attempt_completion>
## plan_mode_respond
## plan_mode_response
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. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
- 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.
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
Usage:
<plan_mode_respond>
<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_respond>
</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
@@ -881,11 +873,11 @@ ACT MODE V.S. PLAN MODE
In each user message, the environment_details will specify the current mode. There are two modes:
- ACT MODE: In this mode, you have access to all tools EXCEPT the plan_mode_respond tool.
- ACT MODE: In this mode, you have access to all tools EXCEPT the plan_mode_response tool.
- In ACT MODE, you use tools to accomplish the user's task. Once you've completed the user's task, you use the attempt_completion tool to present the result of the task to the user.
- PLAN MODE: In this special mode, you have access to the plan_mode_respond tool.
- PLAN MODE: In this special mode, you have access to the plan_mode_response tool.
- In PLAN MODE, the goal is to gather information and get context to create a detailed plan for accomplishing the task, which the user will review and approve before they switch you to ACT MODE to implement the solution.
- In PLAN MODE, when you need to converse with the user or present a plan, you should use the plan_mode_respond tool to deliver your response directly, rather than using <thinking> tags to analyze when to respond. Do not talk about using plan_mode_respond - just use it directly to share your thoughts and provide helpful answers.
- In PLAN MODE, when you need to converse with the user or present a plan, you should use the plan_mode_response tool to deliver your response directly, rather than using <thinking> tags to analyze when to respond. Do not talk about using plan_mode_response - just use it directly to share your thoughts and provide helpful answers.
## What is PLAN MODE?
+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)]
}
-73
View File
@@ -1,73 +0,0 @@
import * as path from "path"
import * as vscode from "vscode"
import fs from "fs/promises"
import { Anthropic } from "@anthropic-ai/sdk"
import { fileExistsAtPath } from "../../utils/fs"
import { ClineMessage } from "../../shared/ExtensionMessage"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
}
export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext, taskId: string): Promise<string> {
const globalStoragePath = context.globalStorageUri.fsPath
const taskDir = path.join(globalStoragePath, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
return taskDir
}
export async function getSavedApiConversationHistory(
context: vscode.ExtensionContext,
taskId: string,
): Promise<Anthropic.MessageParam[]> {
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
return []
}
export async function saveApiConversationHistory(
context: vscode.ExtensionContext,
taskId: string,
apiConversationHistory: Anthropic.MessageParam[],
) {
try {
const filePath = path.join(await ensureTaskDirectoryExists(context, 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 getSavedClineMessages(context: vscode.ExtensionContext, taskId: string): Promise<ClineMessage[]> {
const filePath = path.join(await ensureTaskDirectoryExists(context, 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(context, 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 []
}
export async function saveClineMessages(context: vscode.ExtensionContext, taskId: string, uiMessages: ClineMessage[]) {
try {
const taskDir = await ensureTaskDirectoryExists(context, taskId)
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
await fs.writeFile(filePath, JSON.stringify(uiMessages))
} catch (error) {
console.error("Failed to save ui messages:", error)
}
}

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